Repository: OpenHub-Store/GitHub-Store Branch: main Commit: 223c091207e9 Files: 593 Total size: 2.3 MB Directory structure: gitextract_ol6wqqha/ ├── .claude/ │ └── memory/ │ └── feedback_coding_boundaries.md ├── .coderabbit.yaml ├── .editorconfig ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ ├── custom.md │ │ └── feature_request.md │ └── workflows/ │ └── build-desktop-platforms.yml ├── .gitignore ├── CLAUDE.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── build-logic/ │ ├── convention/ │ │ ├── build.gradle.kts │ │ └── src/ │ │ └── main/ │ │ └── kotlin/ │ │ ├── AndroidApplicationComposeConventionPlugin.kt │ │ ├── AndroidApplicationConventionPlugin.kt │ │ ├── BuildKonfigConventionPlugin.kt │ │ ├── CmpApplicationConventionPlugin.kt │ │ ├── CmpFeatureConventionPlugin.kt │ │ ├── CmpLibraryConventionPlugin.kt │ │ ├── KmpLibraryConventionPlugin.kt │ │ ├── KtlintConventionPlugin.kt │ │ ├── RoomConventionPlugin.kt │ │ └── zed/ │ │ └── rainxch/ │ │ └── githubstore/ │ │ └── convention/ │ │ ├── AndroidCompose.kt │ │ ├── KotlinAndroid.kt │ │ ├── KotlinAndroidTarget.kt │ │ ├── KotlinJvmTarget.kt │ │ ├── KotlinMultiplatform.kt │ │ ├── PathUtil.kt │ │ └── ProjectExt.kt │ ├── gradle.properties │ └── settings.gradle.kts ├── build.gradle.kts ├── composeApp/ │ ├── build.gradle.kts │ ├── proguard-rules.pro │ └── src/ │ ├── androidMain/ │ │ ├── AndroidManifest.xml │ │ ├── kotlin/ │ │ │ └── zed/ │ │ │ └── rainxch/ │ │ │ └── githubstore/ │ │ │ ├── MainActivity.kt │ │ │ └── app/ │ │ │ └── GithubStoreApp.kt │ │ └── res/ │ │ ├── drawable/ │ │ │ ├── ic_launcher_monochrome.xml │ │ │ └── ic_splash.xml │ │ ├── mipmap-anydpi-v26/ │ │ │ ├── ic_launcher.xml │ │ │ └── ic_launcher_round.xml │ │ ├── values/ │ │ │ ├── colors.xml │ │ │ ├── splash.xml │ │ │ └── strings.xml │ │ └── xml/ │ │ ├── filepaths.xml │ │ └── network_security_config.xml │ ├── commonMain/ │ │ ├── composeResources/ │ │ │ └── drawable/ │ │ │ └── ic_github.xml │ │ └── kotlin/ │ │ └── zed/ │ │ └── rainxch/ │ │ └── githubstore/ │ │ ├── Main.kt │ │ ├── MainAction.kt │ │ ├── MainState.kt │ │ ├── MainViewModel.kt │ │ └── app/ │ │ ├── components/ │ │ │ ├── RateLimitDialog.kt │ │ │ └── SessionExpiredDialog.kt │ │ ├── deeplink/ │ │ │ └── DeepLinkParser.kt │ │ ├── desktop/ │ │ │ ├── KeyboardNavigation.kt │ │ │ └── KeyboardNavigationEvent.kt │ │ ├── di/ │ │ │ ├── SharedModules.kt │ │ │ ├── ViewModelsModule.kt │ │ │ └── initKoin.kt │ │ └── navigation/ │ │ ├── AppNavigation.kt │ │ ├── BottomNavigation.kt │ │ ├── BottomNavigationUtils.kt │ │ ├── GithubStoreGraph.kt │ │ └── NavigationUtils.kt │ └── jvmMain/ │ ├── kotlin/ │ │ └── zed/ │ │ └── rainxch/ │ │ └── githubstore/ │ │ ├── DesktopApp.kt │ │ └── DesktopDeepLink.kt │ └── resources/ │ └── logo/ │ └── app_icon.icns ├── core/ │ ├── data/ │ │ ├── .gitignore │ │ ├── build.gradle.kts │ │ ├── schemas/ │ │ │ └── zed.rainxch.core.data.local.db.AppDatabase/ │ │ │ ├── 3.json │ │ │ ├── 4.json │ │ │ ├── 5.json │ │ │ └── 6.json │ │ └── src/ │ │ ├── androidMain/ │ │ │ ├── AndroidManifest.xml │ │ │ ├── aidl/ │ │ │ │ └── zed/ │ │ │ │ └── rainxch/ │ │ │ │ └── core/ │ │ │ │ └── data/ │ │ │ │ └── services/ │ │ │ │ └── shizuku/ │ │ │ │ └── IShizukuInstallerService.aidl │ │ │ └── kotlin/ │ │ │ └── zed/ │ │ │ └── rainxch/ │ │ │ └── core/ │ │ │ └── data/ │ │ │ ├── di/ │ │ │ │ └── PlatformModule.android.kt │ │ │ ├── local/ │ │ │ │ ├── data_store/ │ │ │ │ │ └── createDataStore.kt │ │ │ │ └── db/ │ │ │ │ ├── initDatabase.kt │ │ │ │ └── migrations/ │ │ │ │ ├── MIGRATION_1_2.kt │ │ │ │ ├── MIGRATION_2_3.kt │ │ │ │ ├── MIGRATION_3_4.kt │ │ │ │ ├── MIGRATION_4_5.kt │ │ │ │ └── MIGRATION_5_6.kt │ │ │ ├── network/ │ │ │ │ └── HttpClientFactory.android.kt │ │ │ ├── services/ │ │ │ │ ├── AndroidDownloader.kt │ │ │ │ ├── AndroidFileLocationsProvider.kt │ │ │ │ ├── AndroidInstaller.kt │ │ │ │ ├── AndroidInstallerInfoExtractor.kt │ │ │ │ ├── AndroidLocalizationManager.kt │ │ │ │ ├── AndroidPackageMonitor.kt │ │ │ │ ├── AndroidUpdateScheduleManager.kt │ │ │ │ ├── AutoUpdateWorker.kt │ │ │ │ ├── BootReceiver.kt │ │ │ │ ├── PackageEventReceiver.kt │ │ │ │ ├── UpdateCheckWorker.kt │ │ │ │ ├── UpdateScheduler.kt │ │ │ │ └── shizuku/ │ │ │ │ ├── AndroidInstallerStatusProvider.kt │ │ │ │ ├── ShizukuInstallerServiceImpl.kt │ │ │ │ ├── ShizukuInstallerWrapper.kt │ │ │ │ ├── ShizukuServiceManager.kt │ │ │ │ └── model/ │ │ │ │ └── ShizukuStatus.kt │ │ │ └── utils/ │ │ │ ├── AndroidAppLauncher.kt │ │ │ ├── AndroidBrowserHelper.kt │ │ │ ├── AndroidClipboardHelper.kt │ │ │ └── AndroidShareManager.kt │ │ ├── commonMain/ │ │ │ └── kotlin/ │ │ │ └── zed/ │ │ │ └── rainxch/ │ │ │ └── core/ │ │ │ └── data/ │ │ │ ├── cache/ │ │ │ │ └── CacheManager.kt │ │ │ ├── data_source/ │ │ │ │ ├── TokenStore.kt │ │ │ │ └── impl/ │ │ │ │ └── DefaultTokenStore.kt │ │ │ ├── di/ │ │ │ │ ├── PlatformModule.kt │ │ │ │ └── SharedModule.kt │ │ │ ├── dto/ │ │ │ │ ├── AssetNetwork.kt │ │ │ │ ├── GitHubStarredResponse.kt │ │ │ │ ├── GithubDeviceStartDto.kt │ │ │ │ ├── GithubDeviceTokenErrorDto.kt │ │ │ │ ├── GithubDeviceTokenSuccessDto.kt │ │ │ │ ├── GithubOwnerNetworkModel.kt │ │ │ │ ├── GithubRepoNetworkModel.kt │ │ │ │ ├── GithubRepoSearchResponse.kt │ │ │ │ ├── OwnerNetwork.kt │ │ │ │ ├── ReleaseNetwork.kt │ │ │ │ ├── RepoByIdNetwork.kt │ │ │ │ ├── RepoInfoNetwork.kt │ │ │ │ └── UserProfileNetwork.kt │ │ │ ├── local/ │ │ │ │ ├── data_store/ │ │ │ │ │ └── createDataStoreCore.kt │ │ │ │ └── db/ │ │ │ │ ├── AppDatabase.kt │ │ │ │ ├── dao/ │ │ │ │ │ ├── CacheDao.kt │ │ │ │ │ ├── FavoriteRepoDao.kt │ │ │ │ │ ├── InstalledAppDao.kt │ │ │ │ │ ├── SeenRepoDao.kt │ │ │ │ │ ├── StarredRepoDao.kt │ │ │ │ │ └── UpdateHistoryDao.kt │ │ │ │ └── entities/ │ │ │ │ ├── CacheEntryEntity.kt │ │ │ │ ├── FavoriteRepoEntity.kt │ │ │ │ ├── InstalledAppEntity.kt │ │ │ │ ├── SeenRepoEntity.kt │ │ │ │ ├── StarredRepositoryEntity.kt │ │ │ │ └── UpdateHistoryEntity.kt │ │ │ ├── logging/ │ │ │ │ └── KermitLogger.kt │ │ │ ├── mappers/ │ │ │ │ ├── AssetNetwork.kt │ │ │ │ ├── FavouriteRepoMappers.kt │ │ │ │ ├── GithubAuthMappers.kt │ │ │ │ ├── GithubRepoMapper.kt │ │ │ │ ├── InstalledAppsMappers.kt │ │ │ │ ├── ReleaseNetwork.kt │ │ │ │ ├── StarredRepoMapper.kt │ │ │ │ └── UpdateHistoryMapper.kt │ │ │ ├── network/ │ │ │ │ ├── GitHubClientProvider.kt │ │ │ │ ├── HttpClientFactory.kt │ │ │ │ ├── ProxyManager.kt │ │ │ │ └── interceptor/ │ │ │ │ ├── RateLimitInterceptor.kt │ │ │ │ └── UnauthorizedInterceptor.kt │ │ │ ├── repository/ │ │ │ │ ├── AuthenticationStateImpl.kt │ │ │ │ ├── FavouritesRepositoryImpl.kt │ │ │ │ ├── InstalledAppsRepositoryImpl.kt │ │ │ │ ├── ProxyRepositoryImpl.kt │ │ │ │ ├── RateLimitRepositoryImpl.kt │ │ │ │ ├── SeenReposRepositoryImpl.kt │ │ │ │ ├── StarredRepositoryImpl.kt │ │ │ │ └── TweaksRepositoryImpl.kt │ │ │ └── services/ │ │ │ ├── FileLocationsProvider.kt │ │ │ └── LocalizationManager.kt │ │ └── jvmMain/ │ │ └── kotlin/ │ │ └── zed/ │ │ └── rainxch/ │ │ └── core/ │ │ └── data/ │ │ ├── di/ │ │ │ └── PlatformModule.jvm.kt │ │ ├── local/ │ │ │ ├── data_store/ │ │ │ │ └── createDataStore.kt │ │ │ └── db/ │ │ │ └── initDatabase.kt │ │ ├── model/ │ │ │ ├── LinuxPackageType.kt │ │ │ └── LinuxTerminal.kt │ │ ├── network/ │ │ │ └── HttpClientFactory.jvm.kt │ │ ├── services/ │ │ │ ├── DesktopDownloader.kt │ │ │ ├── DesktopFileLocationsProvider.kt │ │ │ ├── DesktopInstaller.kt │ │ │ ├── DesktopInstallerInfoExtractor.kt │ │ │ ├── DesktopInstallerStatusProvider.kt │ │ │ ├── DesktopLocalizationManager.kt │ │ │ ├── DesktopPackageMonitor.kt │ │ │ └── DesktopUpdateScheduleManager.kt │ │ └── utils/ │ │ ├── DesktopAppLauncher.kt │ │ ├── DesktopBrowserHelper.kt │ │ ├── DesktopClipboardHelper.kt │ │ └── DesktopShareManager.kt │ ├── domain/ │ │ ├── .gitignore │ │ ├── build.gradle.kts │ │ └── src/ │ │ ├── androidMain/ │ │ │ ├── AndroidManifest.xml │ │ │ └── kotlin/ │ │ │ └── zed/ │ │ │ └── rainxch/ │ │ │ └── core/ │ │ │ └── domain/ │ │ │ └── Platform.android.kt │ │ ├── commonMain/ │ │ │ └── kotlin/ │ │ │ └── zed/ │ │ │ └── rainxch/ │ │ │ └── core/ │ │ │ └── domain/ │ │ │ ├── Platform.kt │ │ │ ├── logging/ │ │ │ │ └── GitHubStoreLogger.kt │ │ │ ├── model/ │ │ │ │ ├── ApkPackageInfo.kt │ │ │ │ ├── AppTheme.kt │ │ │ │ ├── AssetArchitectureMatcher.kt │ │ │ │ ├── DeviceApp.kt │ │ │ │ ├── DiscoveryPlatform.kt │ │ │ │ ├── DownloadProgress.kt │ │ │ │ ├── ExportedApp.kt │ │ │ │ ├── FavoriteRepo.kt │ │ │ │ ├── FontTheme.kt │ │ │ │ ├── GithubAsset.kt │ │ │ │ ├── GithubDeviceStart.kt │ │ │ │ ├── GithubDeviceTokenError.kt │ │ │ │ ├── GithubDeviceTokenSuccess.kt │ │ │ │ ├── GithubRelease.kt │ │ │ │ ├── GithubRepoSummary.kt │ │ │ │ ├── GithubUser.kt │ │ │ │ ├── GithubUserProfile.kt │ │ │ │ ├── InstallSource.kt │ │ │ │ ├── InstalledApp.kt │ │ │ │ ├── InstallerType.kt │ │ │ │ ├── PackageChangeType.kt │ │ │ │ ├── PaginatedDiscoveryRepositories.kt │ │ │ │ ├── Platform.kt │ │ │ │ ├── ProxyConfig.kt │ │ │ │ ├── RateLimitException.kt │ │ │ │ ├── RateLimitInfo.kt │ │ │ │ ├── ShizukuAvailability.kt │ │ │ │ ├── StarredRepository.kt │ │ │ │ ├── SystemArchitecture.kt │ │ │ │ ├── SystemPackageInfo.kt │ │ │ │ └── UpdateHistory.kt │ │ │ ├── network/ │ │ │ │ └── Downloader.kt │ │ │ ├── repository/ │ │ │ │ ├── AuthenticationState.kt │ │ │ │ ├── FavouritesRepository.kt │ │ │ │ ├── InstalledAppsRepository.kt │ │ │ │ ├── ProxyRepository.kt │ │ │ │ ├── RateLimitRepository.kt │ │ │ │ ├── SeenReposRepository.kt │ │ │ │ ├── StarredRepository.kt │ │ │ │ └── TweaksRepository.kt │ │ │ ├── system/ │ │ │ │ ├── Installer.kt │ │ │ │ ├── InstallerInfoExtractor.kt │ │ │ │ ├── InstallerStatusProvider.kt │ │ │ │ ├── PackageMonitor.kt │ │ │ │ └── UpdateScheduleManager.kt │ │ │ ├── use_cases/ │ │ │ │ └── SyncInstalledAppsUseCase.kt │ │ │ └── utils/ │ │ │ ├── AppLauncher.kt │ │ │ ├── BrowserHelper.kt │ │ │ ├── ClipboardHelper.kt │ │ │ └── ShareManager.kt │ │ └── jvmMain/ │ │ └── kotlin/ │ │ └── zed/ │ │ └── rainxch/ │ │ └── core/ │ │ └── domain/ │ │ └── Platform.jvm.kt │ └── presentation/ │ ├── .gitignore │ ├── build.gradle.kts │ └── src/ │ ├── androidMain/ │ │ ├── AndroidManifest.xml │ │ └── kotlin/ │ │ └── zed/ │ │ └── rainxch/ │ │ └── core/ │ │ └── presentation/ │ │ ├── theme/ │ │ │ └── Theme.android.kt │ │ └── utils/ │ │ ├── ApplyAndroidSystemBars.android.kt │ │ └── isLiquidFrostAvailable.android.kt │ ├── commonMain/ │ │ ├── composeResources/ │ │ │ ├── drawable/ │ │ │ │ ├── ic_github.xml │ │ │ │ ├── ic_platform_android.xml │ │ │ │ ├── ic_platform_linux.xml │ │ │ │ ├── ic_platform_macos.xml │ │ │ │ └── ic_platform_windows.xml │ │ │ ├── values/ │ │ │ │ └── strings.xml │ │ │ ├── values-ar/ │ │ │ │ └── strings-ar.xml │ │ │ ├── values-bn/ │ │ │ │ └── strings-bn.xml │ │ │ ├── values-es/ │ │ │ │ └── strings-es.xml │ │ │ ├── values-fr/ │ │ │ │ └── strings-fr.xml │ │ │ ├── values-hi/ │ │ │ │ └── strings-hi.xml │ │ │ ├── values-it/ │ │ │ │ └── strings-it.xml │ │ │ ├── values-ja/ │ │ │ │ └── strings-ja.xml │ │ │ ├── values-ko/ │ │ │ │ └── strings-ko.xml │ │ │ ├── values-pl/ │ │ │ │ └── strings-pl.xml │ │ │ ├── values-ru/ │ │ │ │ └── strings-ru.xml │ │ │ ├── values-tr/ │ │ │ │ └── strings-tr.xml │ │ │ └── values-zh-rCN/ │ │ │ └── strings-zh-rCN.xml │ │ └── kotlin/ │ │ └── zed/ │ │ └── rainxch/ │ │ └── core/ │ │ └── presentation/ │ │ ├── components/ │ │ │ ├── ExpressiveCard.kt │ │ │ ├── GitHubStoreImage.kt │ │ │ ├── GithubStoreButton.kt │ │ │ └── RepositoryCard.kt │ │ ├── locals/ │ │ │ ├── LocalBottomNavigationHeight.kt │ │ │ └── LocalBottomNavigationLiquid.kt │ │ ├── model/ │ │ │ ├── DiscoveryRepositoryUi.kt │ │ │ ├── GithubRepoSummaryUi.kt │ │ │ └── GithubUserUi.kt │ │ ├── theme/ │ │ │ ├── Color.kt │ │ │ ├── Theme.kt │ │ │ └── Type.kt │ │ └── utils/ │ │ ├── AppThemeUtil.kt │ │ ├── ApplyAndroidSystemBars.kt │ │ ├── CountFormatter.kt │ │ ├── DiscoveryPlatformUiResources.kt │ │ ├── GithubRepoSummaryMappers.kt │ │ ├── GithubUserMappers.kt │ │ ├── ObserveAsEvents.kt │ │ ├── TimeFormatters.kt │ │ └── isLiquidFrostAvailable.kt │ └── jvmMain/ │ └── kotlin/ │ └── zed/ │ └── rainxch/ │ └── core/ │ └── presentation/ │ ├── theme/ │ │ └── Theme.jvm.kt │ └── utils/ │ ├── ApplyAndroidSystemBars.jvm.kt │ └── isLiquidFrostAvailable.jvm.kt ├── docs/ │ ├── README-BN.md │ ├── README-ES.md │ ├── README-FR.md │ ├── README-HI.md │ ├── README-IT.md │ ├── README-JA.md │ ├── README-KR.md │ ├── README-PL.md │ ├── README-RU.md │ ├── README-TR.md │ └── README-ZH.md ├── fastlane/ │ └── metadata/ │ └── android/ │ └── en-US/ │ ├── full_description.txt │ ├── short_description.txt │ └── title.txt ├── feature/ │ ├── apps/ │ │ ├── CLAUDE.md │ │ ├── data/ │ │ │ ├── .gitignore │ │ │ ├── build.gradle.kts │ │ │ └── src/ │ │ │ ├── androidMain/ │ │ │ │ └── AndroidManifest.xml │ │ │ └── commonMain/ │ │ │ └── kotlin/ │ │ │ └── zed/ │ │ │ └── rainxch/ │ │ │ └── apps/ │ │ │ └── data/ │ │ │ ├── di/ │ │ │ │ └── SharedModule.kt │ │ │ └── repository/ │ │ │ └── AppsRepositoryImpl.kt │ │ ├── domain/ │ │ │ ├── .gitignore │ │ │ ├── build.gradle.kts │ │ │ └── src/ │ │ │ ├── androidMain/ │ │ │ │ └── AndroidManifest.xml │ │ │ └── commonMain/ │ │ │ └── kotlin/ │ │ │ └── zed/ │ │ │ └── rainxch/ │ │ │ └── apps/ │ │ │ └── domain/ │ │ │ ├── model/ │ │ │ │ ├── GithubRepoInfo.kt │ │ │ │ └── ImportResult.kt │ │ │ └── repository/ │ │ │ └── AppsRepository.kt │ │ └── presentation/ │ │ ├── .gitignore │ │ ├── build.gradle.kts │ │ └── src/ │ │ ├── androidMain/ │ │ │ └── AndroidManifest.xml │ │ └── commonMain/ │ │ └── kotlin/ │ │ └── zed/ │ │ └── rainxch/ │ │ └── apps/ │ │ └── presentation/ │ │ ├── AppsAction.kt │ │ ├── AppsEvent.kt │ │ ├── AppsRoot.kt │ │ ├── AppsState.kt │ │ ├── AppsViewModel.kt │ │ ├── components/ │ │ │ └── LinkAppBottomSheet.kt │ │ ├── mappers/ │ │ │ ├── DeviceAppMapper.kt │ │ │ ├── GithubAssetMapper.kt │ │ │ ├── GithubRepoInfoMapper.kt │ │ │ └── InstalledAppMapper.kt │ │ └── model/ │ │ ├── AppItem.kt │ │ ├── DeviceAppUi.kt │ │ ├── GithubAssetUi.kt │ │ ├── GithubRepoInfoUi.kt │ │ ├── GithubUserUi.kt │ │ ├── InstalledAppUi.kt │ │ ├── UpdateAllProgress.kt │ │ └── UpdateState.kt │ ├── auth/ │ │ ├── CLAUDE.md │ │ ├── data/ │ │ │ ├── .gitignore │ │ │ ├── build.gradle.kts │ │ │ └── src/ │ │ │ ├── androidMain/ │ │ │ │ └── AndroidManifest.xml │ │ │ └── commonMain/ │ │ │ └── kotlin/ │ │ │ └── zed/ │ │ │ └── rainxch/ │ │ │ └── auth/ │ │ │ └── data/ │ │ │ ├── di/ │ │ │ │ └── SharedModule.kt │ │ │ ├── network/ │ │ │ │ └── GitHubAuthApi.kt │ │ │ └── repository/ │ │ │ └── AuthenticationRepositoryImpl.kt │ │ ├── domain/ │ │ │ ├── .gitignore │ │ │ ├── build.gradle.kts │ │ │ └── src/ │ │ │ ├── androidMain/ │ │ │ │ └── AndroidManifest.xml │ │ │ └── commonMain/ │ │ │ └── kotlin/ │ │ │ └── zed/ │ │ │ └── rainxch/ │ │ │ └── auth/ │ │ │ └── domain/ │ │ │ └── repository/ │ │ │ └── AuthenticationRepository.kt │ │ └── presentation/ │ │ ├── .gitignore │ │ ├── build.gradle.kts │ │ └── src/ │ │ ├── androidMain/ │ │ │ └── AndroidManifest.xml │ │ └── commonMain/ │ │ └── kotlin/ │ │ └── zed/ │ │ └── rainxch/ │ │ └── auth/ │ │ └── presentation/ │ │ ├── AuthenticationAction.kt │ │ ├── AuthenticationEvents.kt │ │ ├── AuthenticationRoot.kt │ │ ├── AuthenticationState.kt │ │ ├── AuthenticationViewModel.kt │ │ ├── mapper/ │ │ │ └── GithubDeviceStartMapper.kt │ │ └── model/ │ │ ├── AuthLoginState.kt │ │ └── GithubDeviceStartUi.kt │ ├── details/ │ │ ├── CLAUDE.md │ │ ├── data/ │ │ │ ├── .gitignore │ │ │ ├── build.gradle.kts │ │ │ └── src/ │ │ │ ├── androidMain/ │ │ │ │ └── AndroidManifest.xml │ │ │ └── commonMain/ │ │ │ └── kotlin/ │ │ │ └── zed/ │ │ │ └── rainxch/ │ │ │ └── details/ │ │ │ └── data/ │ │ │ ├── di/ │ │ │ │ └── SharedModule.kt │ │ │ ├── dto/ │ │ │ │ └── AttestationsResponse.kt │ │ │ ├── model/ │ │ │ │ └── ReadmeAttempt.kt │ │ │ ├── repository/ │ │ │ │ ├── DetailsRepositoryImpl.kt │ │ │ │ └── TranslationRepositoryImpl.kt │ │ │ └── utils/ │ │ │ ├── ReadmeLocalizationHelper.kt │ │ │ └── preprocessMarkdown.kt │ │ ├── domain/ │ │ │ ├── .gitignore │ │ │ ├── build.gradle.kts │ │ │ └── src/ │ │ │ ├── androidMain/ │ │ │ │ └── AndroidManifest.xml │ │ │ └── commonMain/ │ │ │ └── kotlin/ │ │ │ └── zed/ │ │ │ └── rainxch/ │ │ │ └── details/ │ │ │ └── domain/ │ │ │ ├── model/ │ │ │ │ ├── ReleaseCategory.kt │ │ │ │ ├── RepoStats.kt │ │ │ │ ├── SupportedLanguage.kt │ │ │ │ └── TranslationResult.kt │ │ │ └── repository/ │ │ │ ├── DetailsRepository.kt │ │ │ └── TranslationRepository.kt │ │ └── presentation/ │ │ ├── .gitignore │ │ ├── build.gradle.kts │ │ └── src/ │ │ ├── androidMain/ │ │ │ └── AndroidManifest.xml │ │ └── commonMain/ │ │ └── kotlin/ │ │ └── zed/ │ │ └── rainxch/ │ │ └── details/ │ │ └── presentation/ │ │ ├── DetailsAction.kt │ │ ├── DetailsEvent.kt │ │ ├── DetailsRoot.kt │ │ ├── DetailsState.kt │ │ ├── DetailsViewModel.kt │ │ ├── components/ │ │ │ ├── AppHeader.kt │ │ │ ├── LanguagePicker.kt │ │ │ ├── ReleaseAssetsPicker.kt │ │ │ ├── SmartInstallButton.kt │ │ │ ├── StatItem.kt │ │ │ ├── TranslationControls.kt │ │ │ ├── VersionPicker.kt │ │ │ ├── VersionTypePicker.kt │ │ │ ├── sections/ │ │ │ │ ├── About.kt │ │ │ │ ├── Header.kt │ │ │ │ ├── Logs.kt │ │ │ │ ├── Owner.kt │ │ │ │ ├── ReportIssue.kt │ │ │ │ ├── Stats.kt │ │ │ │ └── WhatsNew.kt │ │ │ └── states/ │ │ │ └── ErrorState.kt │ │ ├── model/ │ │ │ ├── AttestationStatus.kt │ │ │ ├── DownloadStage.kt │ │ │ ├── InstallLogItem.kt │ │ │ ├── LogResult.kt │ │ │ ├── ShowDowngradeWarning.kt │ │ │ ├── SigningKeyWarning.kt │ │ │ ├── SupportedLanguages.kt │ │ │ ├── TranslationState.kt │ │ │ └── TranslationTarget.kt │ │ └── utils/ │ │ ├── LocalTopbarLiquidState.kt │ │ ├── LogResultAsText.kt │ │ ├── MarkdownImageTransformer.kt │ │ ├── MarkdownUtils.kt │ │ └── SystemArchitecture.kt │ ├── dev-profile/ │ │ ├── CLAUDE.md │ │ ├── data/ │ │ │ ├── .gitignore │ │ │ ├── build.gradle.kts │ │ │ └── src/ │ │ │ ├── androidMain/ │ │ │ │ └── AndroidManifest.xml │ │ │ └── commonMain/ │ │ │ └── kotlin/ │ │ │ └── zed/ │ │ │ └── rainxch/ │ │ │ └── devprofile/ │ │ │ └── data/ │ │ │ ├── di/ │ │ │ │ └── SharedModule.kt │ │ │ ├── dto/ │ │ │ │ ├── GitHubRepoResponse.kt │ │ │ │ └── GitHubUserResponse.kt │ │ │ ├── mappers/ │ │ │ │ ├── GitHubRepoToDomain.kt │ │ │ │ └── GitHubUserToDomain.kt │ │ │ └── repository/ │ │ │ └── DeveloperProfileRepositoryImpl.kt │ │ ├── domain/ │ │ │ ├── .gitignore │ │ │ ├── build.gradle.kts │ │ │ └── src/ │ │ │ ├── androidMain/ │ │ │ │ └── AndroidManifest.xml │ │ │ └── commonMain/ │ │ │ └── kotlin/ │ │ │ └── zed/ │ │ │ └── rainxch/ │ │ │ └── devprofile/ │ │ │ └── domain/ │ │ │ ├── model/ │ │ │ │ ├── DeveloperProfile.kt │ │ │ │ ├── DeveloperRepository.kt │ │ │ │ ├── RepoFilterType.kt │ │ │ │ └── RepoSortType.kt │ │ │ └── repository/ │ │ │ └── DeveloperProfileRepository.kt │ │ └── presentation/ │ │ ├── .gitignore │ │ ├── build.gradle.kts │ │ └── src/ │ │ ├── androidMain/ │ │ │ └── AndroidManifest.xml │ │ └── commonMain/ │ │ └── kotlin/ │ │ └── zed/ │ │ └── rainxch/ │ │ └── devprofile/ │ │ └── presentation/ │ │ ├── DeveloperProfileAction.kt │ │ ├── DeveloperProfileRoot.kt │ │ ├── DeveloperProfileState.kt │ │ ├── DeveloperProfileViewModel.kt │ │ └── components/ │ │ ├── DeveloperRepoItem.kt │ │ ├── FilterSortControls.kt │ │ ├── ProfileInfoCard.kt │ │ └── StatsRow.kt │ ├── favourites/ │ │ ├── CLAUDE.md │ │ ├── data/ │ │ │ ├── .gitignore │ │ │ ├── build.gradle.kts │ │ │ └── src/ │ │ │ └── androidMain/ │ │ │ └── AndroidManifest.xml │ │ ├── domain/ │ │ │ ├── .gitignore │ │ │ ├── build.gradle.kts │ │ │ └── src/ │ │ │ └── androidMain/ │ │ │ └── AndroidManifest.xml │ │ └── presentation/ │ │ ├── .gitignore │ │ ├── build.gradle.kts │ │ └── src/ │ │ ├── androidMain/ │ │ │ └── AndroidManifest.xml │ │ └── commonMain/ │ │ └── kotlin/ │ │ └── zed/ │ │ └── rainxch/ │ │ └── favourites/ │ │ └── presentation/ │ │ ├── FavouritesAction.kt │ │ ├── FavouritesRoot.kt │ │ ├── FavouritesState.kt │ │ ├── FavouritesViewModel.kt │ │ ├── components/ │ │ │ └── FavouriteRepositoryItem.kt │ │ ├── mappers/ │ │ │ └── FavouriteRepositoryMapper.kt │ │ └── model/ │ │ └── FavouriteRepository.kt │ ├── home/ │ │ ├── CLAUDE.md │ │ ├── data/ │ │ │ ├── .gitignore │ │ │ ├── build.gradle.kts │ │ │ └── src/ │ │ │ ├── androidMain/ │ │ │ │ └── AndroidManifest.xml │ │ │ └── commonMain/ │ │ │ └── kotlin/ │ │ │ └── zed/ │ │ │ └── rainxch/ │ │ │ └── home/ │ │ │ └── data/ │ │ │ ├── data_source/ │ │ │ │ ├── CachedRepositoriesDataSource.kt │ │ │ │ └── impl/ │ │ │ │ └── CachedRepositoriesDataSourceImpl.kt │ │ │ ├── di/ │ │ │ │ └── SharedModule.kt │ │ │ ├── dto/ │ │ │ │ ├── CachedGithubOwner.kt │ │ │ │ ├── CachedGithubRepoSummary.kt │ │ │ │ └── CachedRepoResponse.kt │ │ │ ├── mappers/ │ │ │ │ └── CachedGithubRepoSummaryMappers.kt │ │ │ └── repository/ │ │ │ └── HomeRepositoryImpl.kt │ │ ├── domain/ │ │ │ ├── .gitignore │ │ │ ├── build.gradle.kts │ │ │ └── src/ │ │ │ ├── androidMain/ │ │ │ │ └── AndroidManifest.xml │ │ │ └── commonMain/ │ │ │ └── kotlin/ │ │ │ └── zed/ │ │ │ └── rainxch/ │ │ │ └── home/ │ │ │ └── domain/ │ │ │ ├── model/ │ │ │ │ └── HomeCategory.kt │ │ │ └── repository/ │ │ │ └── HomeRepository.kt │ │ └── presentation/ │ │ ├── .gitignore │ │ ├── build.gradle.kts │ │ └── src/ │ │ ├── androidMain/ │ │ │ └── AndroidManifest.xml │ │ └── commonMain/ │ │ └── kotlin/ │ │ └── zed/ │ │ └── rainxch/ │ │ └── home/ │ │ └── presentation/ │ │ ├── HomeAction.kt │ │ ├── HomeEvent.kt │ │ ├── HomeRoot.kt │ │ ├── HomeState.kt │ │ ├── HomeViewModel.kt │ │ ├── components/ │ │ │ └── HomeFilterChips.kt │ │ ├── locals/ │ │ │ └── LocalHomeTopBarLiquid.kt │ │ └── utils/ │ │ └── HomeCategoryMapper.kt │ ├── profile/ │ │ ├── CLAUDE.md │ │ ├── data/ │ │ │ ├── .gitignore │ │ │ ├── build.gradle.kts │ │ │ └── src/ │ │ │ ├── androidMain/ │ │ │ │ └── AndroidManifest.xml │ │ │ └── commonMain/ │ │ │ └── kotlin/ │ │ │ └── zed/ │ │ │ └── rainxch/ │ │ │ └── profile/ │ │ │ └── data/ │ │ │ ├── di/ │ │ │ │ └── SharedModule.kt │ │ │ ├── mappers/ │ │ │ │ └── UserProfileMappers.kt │ │ │ └── repository/ │ │ │ └── ProfileRepositoryImpl.kt │ │ ├── domain/ │ │ │ ├── .gitignore │ │ │ ├── build.gradle.kts │ │ │ └── src/ │ │ │ ├── androidMain/ │ │ │ │ └── AndroidManifest.xml │ │ │ └── commonMain/ │ │ │ └── kotlin/ │ │ │ └── zed/ │ │ │ └── rainxch/ │ │ │ └── profile/ │ │ │ └── domain/ │ │ │ ├── model/ │ │ │ │ └── UserProfile.kt │ │ │ └── repository/ │ │ │ └── ProfileRepository.kt │ │ └── presentation/ │ │ ├── .gitignore │ │ ├── build.gradle.kts │ │ └── src/ │ │ ├── androidMain/ │ │ │ └── AndroidManifest.xml │ │ └── commonMain/ │ │ └── kotlin/ │ │ └── zed/ │ │ └── rainxch/ │ │ └── profile/ │ │ └── presentation/ │ │ ├── ProfileAction.kt │ │ ├── ProfileEvent.kt │ │ ├── ProfileRoot.kt │ │ ├── ProfileState.kt │ │ ├── ProfileViewModel.kt │ │ ├── SponsorScreen.kt │ │ ├── components/ │ │ │ ├── LogoutDialog.kt │ │ │ ├── SectionText.kt │ │ │ └── sections/ │ │ │ ├── About.kt │ │ │ ├── Account.kt │ │ │ ├── AccountSection.kt │ │ │ ├── Appearance.kt │ │ │ ├── Installation.kt │ │ │ ├── Network.kt │ │ │ ├── Options.kt │ │ │ ├── Others.kt │ │ │ ├── ProfileSection.kt │ │ │ └── SettingsSection.kt │ │ └── model/ │ │ └── ProxyType.kt │ ├── search/ │ │ ├── CLAUDE.md │ │ ├── data/ │ │ │ ├── .gitignore │ │ │ ├── build.gradle.kts │ │ │ └── src/ │ │ │ ├── androidMain/ │ │ │ │ └── AndroidManifest.xml │ │ │ └── commonMain/ │ │ │ └── kotlin/ │ │ │ └── zed/ │ │ │ └── rainxch/ │ │ │ └── search/ │ │ │ └── data/ │ │ │ ├── di/ │ │ │ │ └── SharedModule.kt │ │ │ ├── dto/ │ │ │ │ ├── AssetNetworkModel.kt │ │ │ │ └── GithubReleaseNetworkModel.kt │ │ │ ├── repository/ │ │ │ │ └── SearchRepositoryImpl.kt │ │ │ └── utils/ │ │ │ └── LruCache.kt │ │ ├── domain/ │ │ │ ├── .gitignore │ │ │ ├── build.gradle.kts │ │ │ └── src/ │ │ │ ├── androidMain/ │ │ │ │ └── AndroidManifest.xml │ │ │ └── commonMain/ │ │ │ └── kotlin/ │ │ │ └── zed/ │ │ │ └── rainxch/ │ │ │ └── domain/ │ │ │ ├── model/ │ │ │ │ ├── ProgrammingLanguage.kt │ │ │ │ ├── SortBy.kt │ │ │ │ └── SortOrder.kt │ │ │ └── repository/ │ │ │ └── SearchRepository.kt │ │ └── presentation/ │ │ ├── .gitignore │ │ ├── build.gradle.kts │ │ └── src/ │ │ ├── androidMain/ │ │ │ └── AndroidManifest.xml │ │ └── commonMain/ │ │ └── kotlin/ │ │ └── zed/ │ │ └── rainxch/ │ │ └── search/ │ │ └── presentation/ │ │ ├── SearchAction.kt │ │ ├── SearchEvent.kt │ │ ├── SearchRoot.kt │ │ ├── SearchState.kt │ │ ├── SearchViewModel.kt │ │ ├── components/ │ │ │ ├── LanguageFilterBottomSheet.kt │ │ │ └── SortByBottomSheet.kt │ │ ├── mappers/ │ │ │ ├── PlatformLanguageMappers.kt │ │ │ ├── SearchPlatformMappers.kt │ │ │ ├── SortByMappers.kt │ │ │ └── SortOrderMapper.kt │ │ ├── model/ │ │ │ ├── ParsedGithubLink.kt │ │ │ ├── ProgrammingLanguageUi.kt │ │ │ ├── SearchPlatformUi.kt │ │ │ ├── SortByUi.kt │ │ │ └── SortOrderUi.kt │ │ └── utils/ │ │ ├── GithubUrlParser.kt │ │ ├── ProgrammingLanguageMapper.kt │ │ ├── SortByMapper.kt │ │ └── SortOrderMapper.kt │ └── starred/ │ ├── CLAUDE.md │ ├── data/ │ │ ├── .gitignore │ │ ├── build.gradle.kts │ │ └── src/ │ │ └── androidMain/ │ │ └── AndroidManifest.xml │ ├── domain/ │ │ ├── .gitignore │ │ ├── build.gradle.kts │ │ └── src/ │ │ └── androidMain/ │ │ └── AndroidManifest.xml │ └── presentation/ │ ├── .gitignore │ ├── build.gradle.kts │ └── src/ │ ├── androidMain/ │ │ └── AndroidManifest.xml │ └── commonMain/ │ └── kotlin/ │ └── zed/ │ └── rainxch/ │ └── starred/ │ └── presentation/ │ ├── StarredReposAction.kt │ ├── StarredReposRoot.kt │ ├── StarredReposState.kt │ ├── StarredReposViewModel.kt │ ├── components/ │ │ └── StarredRepositoryItem.kt │ ├── mappers/ │ │ └── StarredRepoToUiMapper.kt │ ├── model/ │ │ └── StarredRepositoryUi.kt │ └── utils/ │ └── TimeFormatUtils.kt ├── gradle/ │ ├── libs.versions.toml │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat ├── packaging/ │ └── flatpak/ │ ├── README.md │ ├── disable-android-for-flatpak.sh │ ├── flatpak-sources.json │ ├── githubstore.sh │ ├── zed.rainxch.githubstore.desktop │ ├── zed.rainxch.githubstore.metainfo.xml │ └── zed.rainxch.githubstore.yml └── settings.gradle.kts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .claude/memory/feedback_coding_boundaries.md ================================================ --- name: coding_boundaries description: User wants to write all non-trivial logic themselves — Claude should only review, suggest, and handle boilerplate type: feedback --- Never write the "hard parts" — architecture decisions, core business logic, state management patterns, bug fix implementations, algorithm design. Instead, review the user's code, point out issues, suggest approaches, and explain tradeoffs. Let the user implement it. **Why:** The user noticed their coding instincts and skills declining from over-delegating to Claude. They want to stay sharp by doing the thinking and implementation themselves. **How to apply:** - **Hard parts** (user codes): ViewModel logic, repository implementations, state flows, bug fixes, architectural patterns, cache strategies, concurrency handling, UI interaction logic. For these — review, suggest, explain, but don't write the code. - **Boilerplate** (Claude codes): repetitive refactors, string resources, migration scaffolding, import fixes, build config, copy-paste patterns, test scaffolding, file moves/renames. - When the user asks to fix a bug or implement a feature, describe what's wrong and suggest an approach — then let them write it. - If the user explicitly asks "just do it" for something non-trivial, remind them of this agreement first. ================================================ FILE: .coderabbit.yaml ================================================ language: "en-US" early_access: false reviews: profile: "chill" request_changes_workflow: false high_level_summary: true poem: true review_status: true collapse_walkthrough: false auto_review: enabled: true drafts: false ================================================ FILE: .editorconfig ================================================ root = true [*.{kt,kts}] ktlint_standard_filename = disabled ktlint_standard_no-wildcard-imports = disabled ktlint_function_naming_ignore_when_annotated_with = Composable ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: [rainxchzed] buy_me_a_coffee: rainxchzed custom: https://golden-kodee.awardsplatform.com/entry/vote/mNKjQxkX/vnZAamgg?search=8154c88ed0eccba9-70 ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: '' labels: '' assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **Desktop (please complete the following information):** - OS: [e.g. iOS] - Browser [e.g. chrome, safari] - Version [e.g. 22] **Smartphone (please complete the following information):** - Device: [e.g. iPhone6] - OS: [e.g. iOS8.1] - Browser [e.g. stock browser, safari] - Version [e.g. 22] **Additional context** Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/custom.md ================================================ --- name: Custom issue template about: Describe this issue template's purpose here. title: '' labels: '' assignees: '' --- ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: '' labels: '' assignees: '' --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. ================================================ FILE: .github/workflows/build-desktop-platforms.yml ================================================ name: Build Desktop Platform Installers on: push: branches: - generate-installers concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true env: JAVA_VERSION: '21' JAVA_DISTRIBUTION: 'temurin' GRADLE_OPTS: >- -Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.caching=true -Dorg.gradle.vfs.watch=false jobs: build-windows: runs-on: windows-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Validate Gradle wrapper uses: gradle/actions/wrapper-validation@v4 - name: Set up JDK ${{ env.JAVA_VERSION }} uses: actions/setup-java@v4 with: distribution: ${{ env.JAVA_DISTRIBUTION }} java-version: ${{ env.JAVA_VERSION }} - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 with: cache-read-only: false cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} gradle-home-cache-cleanup: true - name: Grant execute permission for gradlew run: chmod +x gradlew shell: bash - name: Build Windows installers (EXE & MSI) run: | set -euo pipefail retry() { local n=1 max=3 delay=5 while true; do echo "Attempt #$n: $*" "$@" && break [ $n -ge $max ] && { echo "Failed after $n attempts."; return 1; } n=$((n+1)); echo "Retrying in ${delay}s..."; sleep $delay; delay=$((delay*2)) done } retry ./gradlew :composeApp:packageExe :composeApp:packageMsi shell: bash - name: Upload Windows installers uses: actions/upload-artifact@v4 with: name: windows-installers path: | composeApp/build/compose/binaries/main/exe/*.exe composeApp/build/compose/binaries/main/msi/*.msi retention-days: 30 compression-level: 6 build-macos: strategy: matrix: include: - os: macos-15-intel arch: x64 - os: macos-latest arch: arm64 runs-on: ${{ matrix.os }} steps: - name: Checkout code uses: actions/checkout@v4 - name: Validate Gradle wrapper uses: gradle/actions/wrapper-validation@v4 - name: Set up JDK ${{ env.JAVA_VERSION }} uses: actions/setup-java@v4 with: distribution: ${{ env.JAVA_DISTRIBUTION }} java-version: ${{ env.JAVA_VERSION }} - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 with: cache-read-only: false cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} gradle-home-cache-cleanup: true - name: Grant execute permission for gradlew run: chmod +x gradlew - name: Build macOS installers (DMG & PKG) run: | set -euo pipefail retry() { local n=1 max=3 delay=5 while true; do echo "Attempt #$n: $*" "$@" && break [ $n -ge $max ] && { echo "Failed after $n attempts."; return 1; } n=$((n+1)); echo "Retrying in ${delay}s..."; sleep $delay; delay=$((delay*2)) done } retry ./gradlew :composeApp:packageDmg :composeApp:packagePkg shell: bash - name: Upload macOS installers uses: actions/upload-artifact@v4 with: name: macos-installers-${{ matrix.arch }} path: | composeApp/build/compose/binaries/main/dmg/*.dmg composeApp/build/compose/binaries/main/pkg/*.pkg retention-days: 30 compression-level: 6 build-linux: strategy: matrix: include: - os: ubuntu-latest label: modern gradle-tasks: >- :composeApp:packageDeb :composeApp:packageRpm :composeApp:packageAppImage - os: ubuntu-22.04 label: debian12-compat gradle-tasks: >- :composeApp:packageDeb :composeApp:packageRpm runs-on: ${{ matrix.os }} steps: - name: Checkout code uses: actions/checkout@v4 - name: Validate Gradle wrapper uses: gradle/actions/wrapper-validation@v4 - name: Set up JDK ${{ env.JAVA_VERSION }} uses: actions/setup-java@v4 with: distribution: ${{ env.JAVA_DISTRIBUTION }} java-version: ${{ env.JAVA_VERSION }} - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 with: cache-read-only: false cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} gradle-home-cache-cleanup: true - name: Grant execute permission for gradlew run: chmod +x gradlew - name: Build Linux installers run: | set -euo pipefail retry() { local n=1 max=3 delay=5 while true; do echo "Attempt #$n: $*" "$@" && break [ $n -ge $max ] && { echo "Failed after $n attempts."; return 1; } n=$((n+1)); echo "Retrying in ${delay}s..."; sleep $delay; delay=$((delay*2)) done } retry ./gradlew ${{ matrix.gradle-tasks }} shell: bash - name: List AppImage build output if: matrix.label == 'modern' run: | echo "=== Listing build output ===" find composeApp/build/compose/binaries/main -maxdepth 3 -type d 2>/dev/null || echo "Directory not found" echo "=== All files ===" find composeApp/build/compose/binaries/main -maxdepth 4 -type f 2>/dev/null | head -30 || echo "No files found" shell: bash - name: Build AppImage with appimagetool if: matrix.label == 'modern' run: | set -euo pipefail # Find the directory containing the app launcher (bin/GitHub-Store) APP_ROOT="" for candidate in \ composeApp/build/compose/binaries/main/app-image/GitHub-Store \ composeApp/build/compose/binaries/main/app/GitHub-Store \ composeApp/build/compose/binaries/main/app-image \ composeApp/build/compose/binaries/main/app; do if [ -f "$candidate/bin/GitHub-Store" ]; then APP_ROOT="$candidate" echo "Found app root at: $candidate" break fi done if [ -z "$APP_ROOT" ]; then echo "ERROR: Could not find app launcher (bin/GitHub-Store)" find composeApp/build/compose/binaries/main -type f -name "GitHub-Store" 2>/dev/null || true exit 1 fi # Download appimagetool wget -q https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage chmod +x appimagetool-x86_64.AppImage # Create AppDir from Compose output APPDIR="GitHub-Store.AppDir" mv "$APP_ROOT" "$APPDIR" # Create AppRun entry point cat > "$APPDIR/AppRun" << 'EOF' #!/bin/bash SELF=$(readlink -f "$0") HERE=${SELF%/*} exec "${HERE}/bin/GitHub-Store" "$@" EOF chmod +x "$APPDIR/AppRun" # Create .desktop file cat > "$APPDIR/github-store.desktop" << 'EOF' [Desktop Entry] Type=Application Name=GitHub Store Exec=GitHub-Store Icon=github-store Categories=Development; Comment=Cross-platform app store for GitHub releases EOF # Copy icon to AppDir root (required by appimagetool) cp "$APPDIR/lib/GitHub-Store.png" "$APPDIR/github-store.png" # Build .AppImage OUTPUT="composeApp/build/compose/binaries/main/GitHub-Store-x86_64.AppImage" UPINFO="gh-releases-zsync|rainxchzed|Github-Store|latest|*x86_64.AppImage.zsync" ARCH=x86_64 APPIMAGE_EXTRACT_AND_RUN=1 ./appimagetool-x86_64.AppImage -u "$UPINFO" "$APPDIR" "$OUTPUT" # appimagetool may place .zsync in the working directory; move it next to the AppImage ZSYNC_NAME="$(basename "$OUTPUT").zsync" if [ -f "$ZSYNC_NAME" ] && [ ! -f "$OUTPUT.zsync" ]; then mv "$ZSYNC_NAME" "$OUTPUT.zsync" fi echo "Created AppImage and zsync:" ls -lh "$OUTPUT" "$OUTPUT.zsync" shell: bash - name: Upload Linux installers uses: actions/upload-artifact@v4 with: name: linux-installers-${{ matrix.label }} path: | composeApp/build/compose/binaries/main/deb/*.deb composeApp/build/compose/binaries/main/rpm/*.rpm retention-days: 30 compression-level: 6 - name: Upload Linux AppImage if: matrix.label == 'modern' uses: actions/upload-artifact@v4 with: name: linux-appimage path: | composeApp/build/compose/binaries/main/GitHub-Store-x86_64.AppImage composeApp/build/compose/binaries/main/GitHub-Store-x86_64.AppImage.zsync if-no-files-found: error retention-days: 30 compression-level: 0 ================================================ FILE: .gitignore ================================================ # Gradle files .gradle/ build/ # Local configuration file (sdk path, etc) local.properties # Log/OS Files *.log # Android Studio generated files and folders captures/ .externalNativeBuild/ .cxx/ *.aab *.apk output-metadata.json # IntelliJ *.iml .idea/ misc.xml deploymentTargetDropDown.xml render.experimental.xml # Keystore files *.jks *.keystore # Google Services (e.g. APIs or Firebase) google-services.json # Android Profiling *.hprof .kotlin/ composeApp/release/baselineProfiles/ composeApp/kotzilla.json ================================================ FILE: CLAUDE.md ================================================ # CLAUDE.md - GitHub Store ## Project Overview GitHub Store is a cross-platform app store for GitHub releases, built with **Kotlin Multiplatform (KMP)** and **Compose Multiplatform**. Targets **Android** (min API 26) and **Desktop** (Windows, macOS, Linux via JVM). Package: `zed.rainxch.githubstore` | Version: 1.6.2 (code 13) | Target SDK: 36 ## Build & Run Commands ```bash # Android ./gradlew :composeApp:assembleDebug ./gradlew :composeApp:assembleRelease # Desktop (run in dev mode) ./gradlew :composeApp:run # Desktop installers ./gradlew :composeApp:packageExe :composeApp:packageMsi # Windows ./gradlew :composeApp:packageDmg :composeApp:packagePkg # macOS ./gradlew :composeApp:packageDeb :composeApp:packageRpm # Linux # Full build check ./gradlew build ``` **Requirements:** JDK 21+ (Temurin recommended), Android SDK for Android builds. ## Project Structure ``` composeApp/ # Main app module (entry points, navigation, DI wiring) src/commonMain/ # Shared UI & app wiring src/androidMain/ # Android entry point (MainActivity) src/jvmMain/ # Desktop entry point (DesktopApp.kt) core/ domain/ # Shared interfaces, models, use cases (no framework deps) data/ # Shared repos, networking (Ktor), database (Room), DI presentation/ # Shared theming (Material 3) & reusable UI components feature/ apps/ # Installed applications management auth/ # GitHub OAuth device flow authentication details/ # Repository details, releases, readme, downloads dev-profile/ # Developer/user profile display favourites/ # Saved favorite repositories (presentation-only) home/ # Main discovery screen (trending, hot, popular) profile/ # User profile, settings, appearance, proxy, Shizuku installer search/ # Repository search with filters starred/ # Starred repositories (presentation-only) build-logic/convention/ # Custom Gradle convention plugins ``` Each feature has up to 3 sub-modules: `domain/` (interfaces & models), `data/` (implementations & DI), `presentation/` (screens & ViewModels). Some features (favourites, starred) are presentation-only and use core repositories directly. ## Architecture **Clean Architecture + MVVM** with strict layer separation per feature module: - **Domain** - Repository interfaces, models, use cases (no framework dependencies) - **Data** - Repository implementations, Ktor API clients, Room DAOs, DTOs, mappers - **Presentation** - ViewModels with `StateFlow`/`Channel`, Compose screens ### State Management Pattern Every screen follows the same State/Action/Event pattern: ```kotlin class XViewModel : ViewModel() { private val _state = MutableStateFlow(XState()) val state = _state.asStateFlow() // or .stateIn() with WhileSubscribed private val _events = Channel() val events = _events.receiveAsFlow() fun onAction(action: XAction) { ... } } ``` - `State` - data class holding all UI state - `Action` - sealed interface for user input (clicks, refreshes, etc.) - `Event` - sealed interface for one-off effects (navigation, toasts, scroll) ### Navigation Type-safe navigation using `@Serializable` sealed interface `GithubStoreGraph`: ``` HomeScreen, SearchScreen, AuthenticationScreen, ProfileScreen, FavouritesScreen, StarredReposScreen, AppsScreen, SponsorScreen DetailsScreen(repositoryId, owner, repo, isComingFromUpdate) DeveloperProfileScreen(username) ``` Routes defined in `composeApp/.../app/navigation/GithubStoreGraph.kt`, wired in `AppNavigation.kt`. ### Dependency Injection **Koin** - modules defined in each feature's `data/di/SharedModule.kt`, registered in `composeApp/.../app/di/initKoin.kt`. ViewModels injected via `koinViewModel()`. ### Core Modules | Module | Purpose | Key Contents | |--------|---------|--------------| | `core/domain` | Shared contracts | Repository interfaces (`FavouritesRepository`, `StarredRepository`, `InstalledAppsRepository`, `ThemesRepository`, `ProxyRepository`, `RateLimitRepository`), models (`GithubRepoSummary`, `GithubRelease`, `InstalledApp`, `ProxyConfig`, `InstallerType`, `ShizukuAvailability`), system interfaces (`Installer`, `InstallerInfoExtractor`, `InstallerStatusProvider`, `PackageMonitor`) | | `core/data` | Shared implementations | `HttpClientFactory` (Ktor + interceptors), `AppDatabase` (Room), `ProxyManager`, `TokenStore`, `LocalizationManager`, platform-specific clients (OkHttp for Android, CIO for Desktop), Shizuku integration (Android: `ShizukuServiceManager`, `ShizukuInstallerWrapper`, `ShizukuInstallerServiceImpl`, `AndroidInstallerStatusProvider`; Desktop: `DesktopInstallerStatusProvider`) | | `core/presentation` | Shared UI | `GithubStoreTheme` (Material 3), reusable components (`RepositoryCard`, `GithubStoreButton`), formatting utils, localized strings (11 languages) | ## Tech Stack | Area | Library | Version | |------|---------|---------| | Language | Kotlin | 2.3.10 | | UI | Compose Multiplatform | 1.10.1 | | HTTP | Ktor | 3.4.0 | | Database | Room | 2.8.4 | | DI | Koin | 4.1.1 | | Serialization | Kotlinx Serialization | 1.10.0 | | Preferences | DataStore | 1.2.0 | | Image Loading | Landscapist (Coil3) | 2.9.5 | | Logging | Kermit | 2.0.8 | | Permissions | MOKO Permissions | 0.20.1 | | Navigation | Navigation Compose | 2.9.2 | | Markdown | Multiplatform Markdown Renderer | 0.39.2 | | Shizuku | Shizuku API | 13.1.5 | | Background Work | WorkManager | 2.11.1 | | Date/Time | Kotlinx Datetime | 0.7.1 | All versions managed in `gradle/libs.versions.toml` (Version Catalog). ## Convention Plugins Custom Gradle plugins in `build-logic/convention/` standardize module setup: | Plugin | Use For | |--------|---------| | `convention.kmp.library` | KMP shared library modules (domain, data) | | `convention.cmp.library` | Compose Multiplatform library modules (core/presentation) | | `convention.cmp.feature` | Feature presentation modules (auto-adds Compose + Koin + core:presentation) | | `convention.cmp.application` | Main app module | | `convention.room` | Room database modules | | `convention.buildkonfig` | Build-time config (API keys from local.properties) | ## Adding a New Feature 1. Create `feature//domain/`, `feature//data/`, `feature//presentation/` 2. Add `build.gradle.kts` in each using the appropriate convention plugin 3. Add `include` entries in `settings.gradle.kts` 4. Define domain interfaces/models in `domain/` 5. Implement repository + Koin DI module in `data/di/SharedModule.kt` 6. Create ViewModel (State/Action/Event pattern) and Screen in `presentation/` 7. Add navigation route to `GithubStoreGraph.kt` and wire in `AppNavigation.kt` 8. Register the Koin module in `initKoin.kt` ## Key Configuration - **GitHub OAuth:** Set `GITHUB_CLIENT_ID` in `local.properties`. Callback URL: `githubstore://callback`. Deep link: `githubstore://repo` - **Shizuku (Android):** Optional silent install via `ShizukuProvider` (registered in AndroidManifest). Requires Shizuku app running with ADB or root. AIDL service passes APK via `ParcelFileDescriptor` to `pm install -S`. Falls back to standard installer on failure. - **Gradle properties:** Config cache enabled, build cache enabled, 4GB Gradle heap, 3GB Kotlin daemon heap - **Code style:** Official Kotlin style (`kotlin.code.style=official`) ## Coding Conventions - Packages follow `zed.rainxch.{module}.{layer}` pattern - Private state properties use underscore prefix: `_state` - Sealed classes/interfaces for type-safe navigation routes, actions, events - Repository pattern: interface in `domain/`, implementation in `data/` - Composition over inheritance via Koin DI - Source sets: `commonMain` for shared, `androidMain` for Android, `jvmMain` for Desktop - Feature CLAUDE.md files exist in each `feature/` directory for module-specific guidance ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. ## Our Standards Examples of behavior that contributes to a positive environment for our community include: * Demonstrating empathy and kindness toward other people * Being respectful of differing opinions, viewpoints, and experiences * Giving and gracefully accepting constructive feedback * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience * Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: * The use of sexualized language or imagery, and sexual attention or advances of any kind * Trolling, insulting or derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or email address, without their explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. ## Scope This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at rainxchzed@gmail.com. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. ## Enforcement Guidelines Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: ### 1. Correction **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. ### 2. Warning **Community Impact**: A violation through a single incident or series of actions. **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. ### 3. Temporary Ban **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within the community. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. ================================================ FILE: CONTRIBUTING.md ================================================ ## How to contribute We'd love to accept your patches and contributions to this project. There are just a few small guidelines you need to follow. ## Preparing a pull request for review Before opening a pull request, please: - Make sure the code builds on all supported targets (Android + Desktop). - Add or update tests where it makes sense. - Keep changes focused and reasonably small. If you introduce new dependencies or modules, briefly explain why in the PR description. ## Code reviews All submissions, including submissions by project members, require review. We use GitHub pull requests for this purpose. Consult [GitHub Help](https://docs.github.com/en/github/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-pull-requests) for more information on using pull requests. ================================================ 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 ================================================

# GitHub Store

API Kotlin Compose Multiplatform material





OpenHub-Store%2FGitHub-Store | Trendshift Featured|HelloGitHub

# 🗺️ Project Overview GitHub Store is a cross-platform app store for GitHub releases, designed to simplify discovering and installing open-source software. It automatically detects installable binaries (APK, EXE, DMG, AppImage, DEB, RPM), provides one-click installation, tracks updates, and presents repository information in a clean, app-store style interface. Built with Kotlin Multiplatform and Compose Multiplatform for Android and Desktop platforms.
Support Palestine
> [!CAUTION] > Free and Open-Source Android is under threat. Google will turn Android into a locked-down platform, restricting your essential freedom to install apps of your choice. Make your voice heard – [keepandroidopen.org](https://keepandroidopen.org/).

# 📔 Wiki & Resources Check out GitHub Store [Wiki](https://github.com/OpenHub-Store/GitHub-Store/wiki) for FAQ and useful information 🌐 **Website:** [github-store.org](https://github-store.org) 💬 **Discord:** [Join the community](https://discord.gg/x9Cvh2Z9qS) 📜 **Privacy Policy:** [github-store.org/privacy-policy](https://github-store.org/privacy-policy/)
---
### 📋 Legal Notice GitHub Store is an independent, open-source project not affiliated with GitHub, Inc. The name describes the app's functionality (discovering GitHub releases) and does not imply trademark ownership. GitHub® is a registered trademark of GitHub, Inc.
---

# 🔃 Download

Get it on Obtainium Get it on GitHub Store

> [!IMPORTANT] > **macOS Users:** You may see a warning that Apple cannot verify GitHub Store. This happens because the app is distributed outside the App Store and is not notarized yet. Allow it via System Settings → Privacy & Security → Open Anyway. ---

# 🏆 Featured In

Featured by HowToMen
HowToMen: Top 20 Best Android Apps 2026 | Top 12 App Stores that are Better than Google Play Store
HelloGitHub: Featured Project

--- ## 🚀 Features - **Smart discovery** - Home sections for “Trending”, “Hot Release”, and “Most Popular” projects with time‑based filters. - Only repos with valid installable assets are shown. - Platform‑aware topic scoring so Android/desktop users see relevant apps first. - Overhauled search with improved relevance ranking and performance. - **Release browser & installs** - Release picker to browse and install from any release, not just the latest. - Fetches all releases for each repository. - Single “Install latest” action, plus an expandable list of all available releases and their installers. - Manual install option with automatic compatibility checks. - **Rich details screen** - App name, version and share action. - Stars, forks, open issues. - Rendered README content (“About this app”). - Release notes with Markdown formatting for any selected release. - List of installers with platform labels and file sizes. - Deep linking support — open repository details directly via URL. - Developer profile screen to explore a developer’s repositories and activity. - **App management** - Open, uninstall, and downgrade installed apps directly from GitHub Store. - Android: APK architecture matching (armv7/armv8), package monitoring, and update tracking. - Desktop (Windows/macOS/Linux): downloads installers to the user’s Downloads folder and opens them with the default handler. - **Starred repositories** - Save and browse your starred GitHub repositories from within the app. - **Network & performance** - Dynamic proxy support for configurable network routing. - Enhanced caching system for faster loading and reduced API usage. --- ## 🔍 How does my app appear in GitHub Store? GitHub Store does not use any private indexing or manual curation rules. Your project can appear automatically if it follows these conditions: 1. **Public repository on GitHub** - Visibility must be `public`. 2. **Installable assets in the latest release** - The latest release must contain at least one asset file with a supported extension: - Android: `.apk` - Windows: `.exe`, `.msi` - macOS: `.dmg`, `.pkg` - Linux: `.deb`, `.rpm`, `.AppImage` - GitHub Store ignores GitHub’s auto‑generated source artifacts (`Source code (zip)` / `Source code (tar.gz)`). 3. **Discoverable by search / topics** - Repositories are fetched via the public GitHub Search API. - Topic, language, and description help the ranking: - Android apps: topics like `android`, `mobile`, `apk`. - Desktop apps: topics like `desktop`, `windows`, `linux`, `macos`, `compose-desktop`, `electron`. - Having at least a few stars makes it more likely to appear under Trending/Hot Release/Most Popular sections. If your repo meets these conditions, GitHub Store can find it through search and show it automatically—no manual submission required. --- ## ✅ Pros / Why use GitHub Store? - **No more hunting through GitHub releases** See only repos that actually ship binaries for your platform. - **Knows what you installed** Tracks apps installed via GitHub Store (Android) and highlights when new releases are available, so you can update them without hunting through GitHub again. - **Always up to date** Installs default to the latest published release, with the option to browse and install from any previous release via the release picker. - **Open source & extensible** Written in KMP with a clear separation between networking, domain logic, and UI—easy to fork, extend, or adapt. --- ## 🔐 GitHub Store APK Signing Certificate All official GitHub Store releases are signed with the following certificate fingerprint: SHA-256: `B7:F2:8E:19:8E:48:C1:93:B0:38:C6:5D:92:DD:F7:BC:07:7B:0D:B5:9E:BC:9B:25:0A:6D:AC:48:C1:18:03:CA` --- ## 🔑 GitHub OAuth Configuration **TL;DR** 1. Create a GitHub OAuth App 2. Copy **Client ID** 3. Put it in `local.properties`
Show full setup guide
### 1 - Create a GitHub OAuth App Go to: **GitHub → Settings → Developer settings → OAuth Apps → New OAuth App** | Field | Value | | ------------------------------ | ------------------------------------------- | | **Application name** | Anything you like (e.g. *GitHub Store Dev*) | | **Homepage URL** | `https://github.com/username/repo_name` | | **Authorization callback URL** | `githubstore://callback` | Then click **Create application**. ### 2 - Copy Your Client ID After creating the app, GitHub will show: - **Client ID** ← you need this - **Client Secret** ← ❗ NOT required for this project ### 3 - Add It to Your Project Open your project’s `local.properties` file (root of the project) and add: ```properties GITHUB_CLIENT_ID=YOUR_CLIENT_ID_HERE ``` ### 4 - Sync & Run Sync the project and run the app. You should now be able to sign in with GitHub. ### ❗ Important Notes - `local.properties` is **not committed to Git**, so your Client ID stays local. - This project only needs the **Client ID** (not the Client Secret). - Each developer should create their own OAuth app for development.
--- ## ☕ Support the project GitHub Store is built and maintained by high school student. Your support helps him: ✅ **Keep the app bug-free** — respond to issues and ship fixes quickly ✅ **Add community-requested features** — implement what users actually need ### 💖 Ways to Support Buy Me a Coffee GitHub Sponsors **Can't sponsor right now?** That's okay! You can still help by: - ⭐ **Starring this repo** — helps others discover GitHub Store - 🐛 **Reporting bugs** — makes the app better for everyone - 📢 **Sharing with friends** — spread the word to other developers and tech and non-tech buddies! - 💬 **Joining our [Discord](https://discord.gg/x9Cvh2Z9qS)** — your feedback shapes the roadmap Every bit of support—financial or not—means the world and keeps this project alive. Thank you! --- ## ⚠️ Disclaimer GitHub Store only helps you discover and download release assets that are already published on GitHub by third‑party developers. The contents, safety, and behavior of those downloads are entirely the responsibility of their respective authors and distributors, not this project. By using GithubStore, you understand and agree that you install and run any downloaded software at your own risk. This project does not review, validate, or guarantee that any installer is safe, free of malware, or fit for any particular purpose. --- ## Star History Star History Chart ![Alt](https://repobeats.axiom.co/api/embed/20367dca127572e9c47db33850979d78df2c6a8b.svg "Repobeats analytics image") ## 📄 License GitHub Store will be released under the **Apache License, Version 2.0**. ``` Copyright 2025 rainxchzed Licensed under the Apache License, Version 2.0 (the "License"); you may not use this project 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: SECURITY.md ================================================ ## Security Policy ## Reporting a Vulnerability We take the security of this repository seriously. If you discover a security vulnerability, please report it responsibly. **Please do not open a public GitHub issue for security vulnerabilities.** Instead, use one of the following methods: - **GitHub Security Advisories** Use the "Report a vulnerability" feature available in the repository’s **Security** tab. - **Email** Send a detailed report to: [rainxch.dev@gmail.com](mailto:rainxch.dev@gmail.com) --- ## What to Include in Your Report To help us assess and resolve the issue quickly, please include: - A clear description of the vulnerability - Steps to reproduce the issue - Proof of concept (PoC), if available - Affected files, endpoints, or components - Potential impact of the vulnerability --- ## Response Timeline We aim to follow this general timeline: - **Acknowledgement:** Within 48 hours - **Initial assessment:** Within 5 business days - **Fix or mitigation:** Based on severity and complexity Timelines may vary depending on the nature of the vulnerability. --- ## Coordinated Disclosure We kindly request that you practice responsible disclosure and avoid sharing details publicly until the vulnerability has been addressed or a fix has been released. --- ## Security Best Practices Contributors are encouraged to: - Follow secure coding practices - Avoid committing secrets or credentials - Use dependency scanning and security tools where possible - Review pull requests for potential security issues --- ## Thank You We appreciate the efforts of the security community and responsible researchers who help keep this project secure. ================================================ FILE: build-logic/convention/build.gradle.kts ================================================ import org.gradle.kotlin.dsl.compileOnly import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { `kotlin-dsl` } group = "zed.rainxch.convention.buildlogic" dependencies { compileOnly(libs.android.gradlePlugin) compileOnly(libs.android.tools.common) compileOnly(libs.kotlin.gradlePlugin) compileOnly(libs.compose.gradlePlugin) compileOnly(libs.androidx.room.gradle.plugin) implementation(libs.buildkonfig.gradlePlugin) implementation(libs.buildkonfig.compiler) implementation(libs.ktlint.gradlePlugin) } java { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } kotlin { compilerOptions { jvmTarget = JvmTarget.JVM_17 } } tasks { validatePlugins { enableStricterValidation = true failOnWarning = true } } gradlePlugin { plugins { register("androidApplication") { id = "zed.rainxch.convention.android.application" implementationClass = "AndroidApplicationConventionPlugin" } register("androidComposeApplication") { id = "zed.rainxch.convention.android.application.compose" implementationClass = "AndroidApplicationComposeConventionPlugin" } register("cmpApplication") { id = "zed.rainxch.convention.cmp.application" implementationClass = "CmpApplicationConventionPlugin" } register("kmpLibrary") { id = "zed.rainxch.convention.kmp.library" implementationClass = "KmpLibraryConventionPlugin" } register("cmpLibrary") { id = "zed.rainxch.convention.cmp.library" implementationClass = "CmpLibraryConventionPlugin" } register("cmpFeature") { id = "zed.rainxch.convention.cmp.feature" implementationClass = "CmpFeatureConventionPlugin" } register("buildKonfig") { id = "zed.rainxch.convention.buildkonfig" implementationClass = "BuildKonfigConventionPlugin" } register("room") { id = "zed.rainxch.convention.room" implementationClass = "RoomConventionPlugin" } register("ktlint") { id = "zed.rainxch.convention.ktlint" implementationClass = "KtlintConventionPlugin" } } } ================================================ FILE: build-logic/convention/src/main/kotlin/AndroidApplicationComposeConventionPlugin.kt ================================================ import com.android.build.api.dsl.ApplicationExtension import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.getByType import zed.rainxch.githubstore.convention.configureAndroidCompose class AndroidApplicationComposeConventionPlugin : Plugin { override fun apply(target: Project) { with(target) { with(pluginManager) { apply("zed.rainxch.convention.android.application") apply("org.jetbrains.kotlin.plugin.compose") apply("zed.rainxch.convention.ktlint") } val commonExtension = extensions.getByType() configureAndroidCompose(commonExtension) } } } ================================================ FILE: build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt ================================================ import com.android.build.api.dsl.ApplicationExtension import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.configure import zed.rainxch.githubstore.convention.configureKotlinAndroid import zed.rainxch.githubstore.convention.libs class AndroidApplicationConventionPlugin : Plugin { override fun apply(target: Project) { with(target) { with(pluginManager) { apply("com.android.application") } extensions.configure { namespace = "zed.rainxch.githubstore" compileSdk = libs .findVersion("projectCompileSdkVersion") .get() .toString() .toInt() defaultConfig { applicationId = libs.findVersion("projectApplicationId").get().toString() minSdk = libs .findVersion("projectMinSdkVersion") .get() .toString() .toInt() targetSdk = libs .findVersion("projectTargetSdkVersion") .get() .toString() .toInt() versionCode = libs .findVersion("projectVersionCode") .get() .toString() .toInt() versionName = libs.findVersion("projectVersionName").get().toString() } packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } buildTypes { getByName("release") { isMinifyEnabled = true isShrinkResources = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro", ) } } configureKotlinAndroid(this) } } } } ================================================ FILE: build-logic/convention/src/main/kotlin/BuildKonfigConventionPlugin.kt ================================================ import com.codingfeline.buildkonfig.compiler.FieldSpec import com.codingfeline.buildkonfig.gradle.BuildKonfigExtension import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.configure import zed.rainxch.githubstore.convention.libs import zed.rainxch.githubstore.convention.pathToPackageName import java.util.Properties class BuildKonfigConventionPlugin : Plugin { override fun apply(target: Project) { with(target) { with(pluginManager) { apply("com.codingfeline.buildkonfig") } extensions.configure { packageName = target.pathToPackageName() defaultConfigs { val localProps = Properties().apply { val file = rootProject.file("local.properties") if (file.exists()) file.inputStream().use { this.load(it) } } val githubClientId = ( localProps.getProperty("GITHUB_CLIENT_ID") ?: "Ov23linTY28VFpFjFiI9" ).trim() val versionName = libs.findVersion("projectVersionName").get().toString() buildConfigField(FieldSpec.Type.STRING, "GITHUB_CLIENT_ID", githubClientId) buildConfigField(FieldSpec.Type.STRING, "VERSION_NAME", versionName) } } } } } ================================================ FILE: build-logic/convention/src/main/kotlin/CmpApplicationConventionPlugin.kt ================================================ import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.dependencies import zed.rainxch.githubstore.convention.configureAndroidTarget import zed.rainxch.githubstore.convention.configureJvmTarget import zed.rainxch.githubstore.convention.libs class CmpApplicationConventionPlugin : Plugin { override fun apply(target: Project) { with(target) { with(pluginManager) { apply("zed.rainxch.convention.android.application.compose") apply("org.jetbrains.kotlin.multiplatform") apply("org.jetbrains.compose") apply("org.jetbrains.kotlin.plugin.compose") } configureAndroidTarget() configureJvmTarget() dependencies { "debugImplementation"(libs.findLibrary("androidx-compose-ui-tooling").get()) } } } } ================================================ FILE: build-logic/convention/src/main/kotlin/CmpFeatureConventionPlugin.kt ================================================ import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.dependencies import zed.rainxch.githubstore.convention.libs class CmpFeatureConventionPlugin : Plugin { override fun apply(target: Project) { with(target) { with(pluginManager) { apply("zed.rainxch.convention.cmp.library") } dependencies { "commonMainImplementation"(project(":core:presentation")) "commonMainImplementation"(platform(libs.findLibrary("koin-bom").get())) "androidMainImplementation"(platform(libs.findLibrary("koin-bom").get())) "commonMainImplementation"(libs.findLibrary("koin-compose").get()) "commonMainImplementation"(libs.findLibrary("koin-compose-viewmodel").get()) "commonMainImplementation"(libs.findLibrary("jetbrains-compose-runtime").get()) "commonMainImplementation"(libs.findLibrary("jetbrains-compose-viewmodel").get()) "commonMainImplementation"(libs.findLibrary("jetbrains-lifecycle-viewmodel").get()) "commonMainImplementation"(libs.findLibrary("jetbrains-lifecycle-compose").get()) "commonMainImplementation"(libs.findLibrary("jetbrains-lifecycle-viewmodel-savedstate").get()) "commonMainImplementation"(libs.findLibrary("jetbrains-savedstate").get()) "commonMainImplementation"(libs.findLibrary("jetbrains-bundle").get()) "commonMainImplementation"(libs.findLibrary("jetbrains-compose-navigation").get()) "androidMainImplementation"(libs.findLibrary("koin-android").get()) "androidMainImplementation"(libs.findLibrary("koin-androidx-compose").get()) "androidMainImplementation"(libs.findLibrary("koin-androidx-navigation").get()) "androidMainImplementation"(libs.findLibrary("koin-core-viewmodel").get()) } } } } ================================================ FILE: build-logic/convention/src/main/kotlin/CmpLibraryConventionPlugin.kt ================================================ import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.dependencies import zed.rainxch.githubstore.convention.libs import kotlin.text.get class CmpLibraryConventionPlugin : Plugin { override fun apply(target: Project) { with(target) { with(pluginManager) { apply("zed.rainxch.convention.kmp.library") apply("org.jetbrains.kotlin.plugin.compose") apply("org.jetbrains.compose") } dependencies { "commonMainImplementation"(libs.findLibrary("jetbrains-compose-ui").get()) "commonMainImplementation"(libs.findLibrary("jetbrains-compose-foundation").get()) "commonMainImplementation"(libs.findLibrary("jetbrains-compose-material3").get()) "commonMainImplementation"(libs.findLibrary("jetbrains-compose-material-icons-extended").get()) "debugImplementation"(libs.findLibrary("androidx-compose-ui-tooling").get()) } } } } ================================================ FILE: build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt ================================================ import com.android.build.api.dsl.LibraryExtension import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.dependencies import zed.rainxch.githubstore.convention.configureKotlinAndroid import zed.rainxch.githubstore.convention.configureKotlinMultiplatform import zed.rainxch.githubstore.convention.libs import zed.rainxch.githubstore.convention.pathToResourcePrefix class KmpLibraryConventionPlugin : Plugin { override fun apply(target: Project) { with(target) { with(pluginManager) { apply("com.android.library") apply("org.jetbrains.kotlin.multiplatform") apply("org.jetbrains.kotlin.plugin.serialization") } configureKotlinMultiplatform() extensions.configure { configureKotlinAndroid(this) resourcePrefix = this@with.pathToResourcePrefix() experimentalProperties["android.experimental.kmp.enableAndroidResources"] = "true" } dependencies { "commonMainImplementation"(libs.findLibrary("kotlinx-serialization-json").get()) } } } } ================================================ FILE: build-logic/convention/src/main/kotlin/KtlintConventionPlugin.kt ================================================ import org.gradle.api.Plugin import org.gradle.api.Project import org.jlleitschuh.gradle.ktlint.KtlintExtension class KtlintConventionPlugin : Plugin { override fun apply(target: Project) { with(target) { pluginManager.apply("org.jlleitschuh.gradle.ktlint") extensions.configure(KtlintExtension::class.java) { version.set("1.8.0") outputToConsole.set(true) ignoreFailures.set(true) filter { exclude("**/generated/**") exclude("**/build/**") exclude("**/*.g.kt") exclude("**/schemas/**") } reporters { reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.PLAIN) reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.HTML) } } } } } ================================================ FILE: build-logic/convention/src/main/kotlin/RoomConventionPlugin.kt ================================================ import androidx.room.gradle.RoomExtension import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.dependencies import zed.rainxch.githubstore.convention.libs class RoomConventionPlugin : Plugin { override fun apply(target: Project) { with(target) { with(pluginManager) { apply("com.google.devtools.ksp") apply("androidx.room") } extensions.configure { schemaDirectory("$projectDir/schemas") } dependencies { "commonMainApi"(libs.findLibrary("androidx-room-runtime").get()) "commonMainApi"(libs.findLibrary("sqlite-bundled").get()) "kspAndroid"(libs.findLibrary("androidx-room-compiler").get()) "kspJvm"(libs.findLibrary("androidx-room-compiler").get()) } } } } ================================================ FILE: build-logic/convention/src/main/kotlin/zed/rainxch/githubstore/convention/AndroidCompose.kt ================================================ package zed.rainxch.githubstore.convention import com.android.build.api.dsl.CommonExtension import org.gradle.api.Project import org.gradle.kotlin.dsl.dependencies fun Project.configureAndroidCompose(commonExtension: CommonExtension<*, *, *, *, *, *>) { with(commonExtension) { buildFeatures { compose = true } dependencies { val composeBom = libs.findLibrary("androidx-compose-bom").get() "implementation"(platform(composeBom)) "testImplementation"(platform(composeBom)) "debugImplementation"(libs.findLibrary("androidx-compose-ui-tooling-preview").get()) "debugImplementation"(libs.findLibrary("androidx-compose-ui-tooling").get()) } } } ================================================ FILE: build-logic/convention/src/main/kotlin/zed/rainxch/githubstore/convention/KotlinAndroid.kt ================================================ package zed.rainxch.githubstore.convention import com.android.build.api.dsl.CommonExtension import org.gradle.api.JavaVersion import org.gradle.api.Project import org.gradle.kotlin.dsl.dependencies import org.gradle.kotlin.dsl.withType import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile internal fun Project.configureKotlinAndroid(commonExtension: CommonExtension<*, *, *, *, *, *>) { with(commonExtension) { compileSdk = libs .findVersion("projectCompileSdkVersion") .get() .toString() .toInt() defaultConfig.minSdk = libs .findVersion("projectMinSdkVersion") .get() .toString() .toInt() compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 isCoreLibraryDesugaringEnabled = true } configureKotlin() dependencies { "coreLibraryDesugaring" { libs.findLibrary("android-desugarJdkLibs").get() } } } } internal fun Project.configureKotlin() { tasks.withType().configureEach { compilerOptions { jvmTarget.set(JvmTarget.JVM_17) freeCompilerArgs.add( "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", ) } } } ================================================ FILE: build-logic/convention/src/main/kotlin/zed/rainxch/githubstore/convention/KotlinAndroidTarget.kt ================================================ package zed.rainxch.githubstore.convention import org.gradle.api.Project import org.gradle.kotlin.dsl.configure import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension internal fun Project.configureAndroidTarget() { extensions.configure { androidTarget { compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } } } } ================================================ FILE: build-logic/convention/src/main/kotlin/zed/rainxch/githubstore/convention/KotlinJvmTarget.kt ================================================ package zed.rainxch.githubstore.convention import org.gradle.api.Project import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.getting import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension import java.util.Properties internal fun Project.configureJvmTarget() { extensions.configure { jvm() } } ================================================ FILE: build-logic/convention/src/main/kotlin/zed/rainxch/githubstore/convention/KotlinMultiplatform.kt ================================================ package zed.rainxch.githubstore.convention import com.android.build.api.dsl.LibraryExtension import org.gradle.api.Project import org.gradle.kotlin.dsl.configure import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension internal fun Project.configureKotlinMultiplatform() { extensions.configure { namespace = this@configureKotlinMultiplatform.pathToPackageName() } configureAndroidTarget() configureJvmTarget() extensions.configure { compilerOptions { freeCompilerArgs.add("-Xexpect-actual-classes") freeCompilerArgs.add("-Xmulti-dollar-interpolation") freeCompilerArgs.add("-opt-in=kotlin.RequiresOptIn") freeCompilerArgs.add("-opt-in=kotlin.time.ExperimentalTime") } } } ================================================ FILE: build-logic/convention/src/main/kotlin/zed/rainxch/githubstore/convention/PathUtil.kt ================================================ package zed.rainxch.githubstore.convention import org.gradle.api.Project import java.util.Locale fun Project.pathToPackageName(): String { val relativePackageName = path .replace(":", ".") .replace("-", "_") .lowercase() return "zed.rainxch$relativePackageName" } fun Project.pathToResourcePrefix(): String = path .replace(":", "_ ") .lowercase() .drop(1) + "_" ================================================ FILE: build-logic/convention/src/main/kotlin/zed/rainxch/githubstore/convention/ProjectExt.kt ================================================ package zed.rainxch.githubstore.convention import org.gradle.api.Project import org.gradle.api.artifacts.VersionCatalog import org.gradle.api.artifacts.VersionCatalogsExtension import org.gradle.kotlin.dsl.getByType val Project.libs: VersionCatalog get() = extensions.getByType().named("libs") ================================================ FILE: build-logic/gradle.properties ================================================ org.gradle.parallel=true org.gradle.caching=true org.gradle.configureondemand=true ================================================ FILE: build-logic/settings.gradle.kts ================================================ @file:Suppress("UnstableApiUsage") rootProject.name = "build-logic" dependencyResolutionManagement { repositories { google() mavenCentral() gradlePluginPortal() } versionCatalogs { create("libs") { from(files("../gradle/libs.versions.toml")) } } } include(":convention") ================================================ FILE: build.gradle.kts ================================================ plugins { id("io.github.jwharm.flatpak-gradle-generator") version "1.7.0" alias(libs.plugins.android.application) apply false alias(libs.plugins.android.library) apply false alias(libs.plugins.compose.hot.reload) apply false alias(libs.plugins.compose.multiplatform) apply false alias(libs.plugins.compose.compiler) apply false alias(libs.plugins.kotlin.multiplatform) apply false alias(libs.plugins.android.kotlin.multiplatform.library) apply false alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.ksp) apply false alias(libs.plugins.room) apply false } tasks.named("flatpakGradleGenerator") { outputFile.set(layout.buildDirectory.file("flatpak-sources.json")) } subprojects { afterEvaluate { tasks.configureEach { when (name) { "preBuild", "compileKotlinJvm", "compileKotlinAndroid", -> { val ktlintFormat = tasks.findByName("ktlintFormat") if (ktlintFormat != null) dependsOn(ktlintFormat) } } } } } ================================================ FILE: composeApp/build.gradle.kts ================================================ import org.jetbrains.compose.desktop.application.dsl.TargetFormat plugins { alias(libs.plugins.convention.cmp.application) alias(libs.plugins.kotlin.serialization) alias(libs.plugins.compose.hot.reload) } android { dependenciesInfo { includeInApk = false includeInBundle = false } } kotlin { sourceSets { androidMain.dependencies { implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.activity.compose) implementation(libs.core.splashscreen) implementation(libs.koin.android) } commonMain.dependencies { implementation(projects.core.data) implementation(projects.core.domain) implementation(projects.core.presentation) implementation(projects.feature.apps.domain) implementation(projects.feature.apps.data) implementation(projects.feature.apps.presentation) implementation(projects.feature.auth.domain) implementation(projects.feature.auth.data) implementation(projects.feature.auth.presentation) implementation(projects.feature.details.domain) implementation(projects.feature.details.data) implementation(projects.feature.details.presentation) implementation(projects.feature.devProfile.domain) implementation(projects.feature.devProfile.data) implementation(projects.feature.devProfile.presentation) implementation(projects.feature.favourites.domain) implementation(projects.feature.favourites.data) implementation(projects.feature.favourites.presentation) implementation(projects.feature.home.domain) implementation(projects.feature.home.data) implementation(projects.feature.home.presentation) implementation(projects.feature.search.domain) implementation(projects.feature.search.data) implementation(projects.feature.search.presentation) implementation(projects.feature.profile.domain) implementation(projects.feature.profile.data) implementation(projects.feature.profile.presentation) implementation(projects.feature.starred.domain) implementation(projects.feature.starred.data) implementation(projects.feature.starred.presentation) implementation(libs.jetbrains.compose.navigation) implementation(libs.bundles.koin.common) implementation(libs.liquid) implementation(libs.jetbrains.compose.material.icons.extended) implementation(libs.touchlab.kermit) implementation(libs.kotlinx.collections.immutable) implementation(compose.runtime) implementation(compose.foundation) implementation(libs.jetbrains.compose.material3) implementation(compose.ui) implementation(compose.components.resources) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.jetbrains.compose.viewmodel) implementation(libs.jetbrains.lifecycle.compose) } jvmMain { dependencies { implementation(projects.core.presentation) implementation(compose.desktop.currentOs) implementation(libs.kotlinx.coroutines.swing) implementation(libs.kotlin.stdlib) implementation(libs.koin.compose) implementation(libs.koin.compose.viewmodel) implementation(libs.koin.compose.viewmodel) implementation(compose.desktop.linux_x64) implementation(compose.desktop.linux_arm64) implementation(compose.desktop.macos_x64) implementation(compose.desktop.macos_arm64) implementation(compose.desktop.windows_x64) implementation(compose.desktop.windows_arm64) implementation(libs.slf4j.simple) } } } } compose.desktop { application { mainClass = "zed.rainxch.githubstore.DesktopAppKt" nativeDistributions { packageName = "GitHub-Store" packageVersion = libs.versions.projectVersionName .get() .toString() vendor = "rainxchzed" includeAllModules = true val currentOs = org.gradle.internal.os.OperatingSystem .current() targetFormats( *when { currentOs.isWindows -> arrayOf(TargetFormat.Exe, TargetFormat.Msi) currentOs.isMacOsX -> arrayOf(TargetFormat.Dmg, TargetFormat.Pkg) currentOs.isLinux -> arrayOf(TargetFormat.Deb, TargetFormat.Rpm, TargetFormat.AppImage) else -> emptyArray() }, ) windows { iconFile.set(project.file("src/jvmMain/resources/logo/app_icon.ico")) menuGroup = "Github Store" shortcut = true perUserInstall = true } macOS { iconFile.set(project.file("src/jvmMain/resources/logo/app_icon.icns")) bundleID = "zed.rainxch.githubstore" infoPlist { extraKeysRawXml = """ CFBundleURLTypes CFBundleURLName GitHub Store Deep Link CFBundleURLSchemes githubstore """.trimIndent() } } linux { iconFile.set(project.file("src/jvmMain/resources/logo/app_icon.png")) appRelease = libs.versions.projectVersionName .get() .toString() debPackageVersion = libs.versions.projectVersionName .get() .toString() menuGroup = "Development" appCategory = "Development" } } } } ================================================ FILE: composeApp/proguard-rules.pro ================================================ # ============================================================================ # ProGuard / R8 Rules for GitHub Store (KMP + Compose Multiplatform) # ============================================================================ # Used with: proguard-android-optimize.txt (enables optimization passes) # ============================================================================ # ── General Attributes ────────────────────────────────────────────────────── -keepattributes Signature -keepattributes *Annotation* -keepattributes InnerClasses,EnclosingMethod -keepattributes SourceFile,LineNumberTable -keepattributes Exceptions # ── Kotlin Core ───────────────────────────────────────────────────────────── # Keep Kotlin metadata for reflection used by serialization & Koin -keep class kotlin.Metadata { *; } -keep class kotlin.reflect.jvm.internal.** { *; } -dontwarn kotlin.** -dontwarn kotlinx.** # ── Kotlin Coroutines ────────────────────────────────────────────────────── -keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {} -keepnames class kotlinx.coroutines.CoroutineExceptionHandler {} -keepclassmembernames class kotlinx.** { volatile ; } -dontwarn kotlinx.coroutines.** # ── Kotlinx Serialization ────────────────────────────────────────────────── # Serialization engine internals -keep class kotlinx.serialization.** { *; } -keepclassmembers class kotlinx.serialization.json.** { *** Companion; } -dontnote kotlinx.serialization.** # Generated serializers for ALL @Serializable classes -keep class **$$serializer { *; } -keepclassmembers @kotlinx.serialization.Serializable class ** { *** Companion; *** INSTANCE; kotlinx.serialization.KSerializer serializer(...); } # App @Serializable classes (DTOs, models, navigation routes) across all packages -keep @kotlinx.serialization.Serializable class zed.rainxch.** { *; } -keep,includedescriptorclasses class zed.rainxch.**$$serializer { *; } -keepclassmembers @kotlinx.serialization.Serializable class zed.rainxch.** { *** Companion; } # ── Navigation Routes ────────────────────────────────────────────────────── # Type-safe navigation requires these classes to survive R8 -keep class zed.rainxch.githubstore.app.navigation.GithubStoreGraph { *; } -keep class zed.rainxch.githubstore.app.navigation.GithubStoreGraph$* { *; } # ── Network DTOs – Core Module ───────────────────────────────────────────── -keep class zed.rainxch.core.data.dto.** { *; } # ── Network DTOs – Feature Modules ───────────────────────────────────────── -keep class zed.rainxch.search.data.dto.** { *; } -keep class zed.rainxch.devprofile.data.dto.** { *; } -keep class zed.rainxch.home.data.dto.** { *; } # ── Domain Models ────────────────────────────────────────────────────────── -keep class zed.rainxch.core.domain.model.GithubRepoSummary { *; } -keep class zed.rainxch.core.domain.model.GithubUser { *; } # Keep enums used by Room TypeConverters and serialization -keep class zed.rainxch.core.domain.model.InstallSource { *; } -keep class zed.rainxch.core.domain.model.AppTheme { *; } -keep class zed.rainxch.core.domain.model.FontTheme { *; } -keep class zed.rainxch.core.domain.model.Platform { *; } -keep class zed.rainxch.core.domain.model.SystemArchitecture { *; } -keep class zed.rainxch.core.domain.model.PackageChangeType { *; } # ── Room Database ────────────────────────────────────────────────────────── # Database class and generated implementation -keep class zed.rainxch.core.data.local.db.AppDatabase { *; } -keep class zed.rainxch.core.data.local.db.AppDatabase_Impl { *; } # Entities -keep class zed.rainxch.core.data.local.db.entities.** { *; } # DAOs -keep interface zed.rainxch.core.data.local.db.dao.** { *; } -keep class zed.rainxch.core.data.local.db.dao.** { *; } # Room runtime -keep class androidx.room.** { *; } -dontwarn androidx.room.** # ── Ktor ─────────────────────────────────────────────────────────────────── # Engine discovery, plugin system, and content negotiation use reflection -keep class io.ktor.client.engine.** { *; } -keep class io.ktor.client.plugins.** { *; } -keep class io.ktor.serialization.** { *; } -keep class io.ktor.utils.io.** { *; } -keep class io.ktor.http.** { *; } -keepnames class io.ktor.** { *; } -dontwarn io.ktor.** -dontwarn java.lang.management.** # ── OkHttp (Ktor engine) ────────────────────────────────────────────────── -keep class okhttp3.internal.platform.** { *; } -keepnames class okhttp3.internal.publicsuffix.PublicSuffixDatabase -dontwarn okhttp3.** -dontwarn org.bouncycastle.** -dontwarn org.openjsse.** # ── Okio ─────────────────────────────────────────────────────────────────── -dontwarn okio.** # ── SSL/TLS ──────────────────────────────────────────────────────────────── -keep class org.conscrypt.** { *; } -dontwarn org.conscrypt.** # ── Koin DI ──────────────────────────────────────────────────────────────── # Koin uses reflection for constructor injection -keep class org.koin.** { *; } -keep interface org.koin.** { *; } -dontwarn org.koin.** # Keep ViewModels so Koin can instantiate them -keep class zed.rainxch.**.presentation.**ViewModel { *; } -keep class zed.rainxch.**.presentation.**ViewModel$* { *; } # ── Compose / AndroidX ──────────────────────────────────────────────────── # Compose runtime and navigation (most rules come bundled with the library) -dontwarn androidx.compose.** -dontwarn androidx.lifecycle.** # ── DataStore ────────────────────────────────────────────────────────────── -keep class androidx.datastore.** { *; } -keepclassmembers class androidx.datastore.preferences.** { *; } -dontwarn androidx.datastore.** # ── Landscapist / Coil3 (Image Loading) ──────────────────────────────────── -keep class com.skydoves.landscapist.** { *; } -keep interface com.skydoves.landscapist.** { *; } -keep class coil3.** { *; } -dontwarn coil3.** -dontwarn com.skydoves.landscapist.** # ── Multiplatform Markdown Renderer ──────────────────────────────────────── -keep class com.mikepenz.markdown.** { *; } -keep class org.intellij.markdown.** { *; } -dontwarn com.mikepenz.markdown.** -dontwarn org.intellij.markdown.** # ── Kermit Logging ───────────────────────────────────────────────────────── -keep class co.touchlab.kermit.** { *; } -dontwarn co.touchlab.kermit.** # ── MOKO Permissions ────────────────────────────────────────────────────── -keep class dev.icerock.moko.permissions.** { *; } -dontwarn dev.icerock.moko.** # ── BuildKonfig (Generated Build Constants) ──────────────────────────────── -keep class zed.rainxch.githubstore.BuildConfig { *; } -keep class zed.rainxch.**.BuildKonfig { *; } -keep class **.BuildKonfig { *; } # ── AndroidX Security / Crypto ───────────────────────────────────────────── -keep class androidx.security.crypto.** { *; } -keep class com.google.crypto.tink.** { *; } -dontwarn com.google.crypto.tink.** -dontwarn com.google.errorprone.annotations.** # ── Firebase (if integrated) ────────────────────────────────────────────── -keep class com.google.firebase.** { *; } -dontwarn com.google.firebase.** # ── Enum safety ──────────────────────────────────────────────────────────── # Keep all enum values and valueOf methods (used by serialization/Room) -keepclassmembers enum * { public static **[] values(); public static ** valueOf(java.lang.String); } # ── Parcelable ───────────────────────────────────────────────────────────── -keepclassmembers class * implements android.os.Parcelable { public static final ** CREATOR; } # ── Java Serializable Compatibility ─────────────────────────────────────── -keepnames class * implements java.io.Serializable -keepclassmembers class * implements java.io.Serializable { static final long serialVersionUID; private static final java.io.ObjectStreamField[] serialPersistentFields; !static !transient ; private void writeObject(java.io.ObjectOutputStream); private void readObject(java.io.ObjectInputStream); java.lang.Object writeReplace(); java.lang.Object readResolve(); } # ── Suppress Warnings for Missing Classes ────────────────────────────────── -dontwarn java.lang.invoke.StringConcatFactory -dontwarn javax.annotation.** -dontwarn org.slf4j.** -dontwarn org.codehaus.mojo.animal_sniffer.** ================================================ FILE: composeApp/src/androidMain/AndroidManifest.xml ================================================ ================================================ FILE: composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/MainActivity.kt ================================================ package zed.rainxch.githubstore import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.tooling.preview.Preview import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.util.Consumer import org.koin.android.ext.android.inject import zed.rainxch.core.data.utils.AndroidShareManager import zed.rainxch.core.domain.utils.ShareManager import zed.rainxch.githubstore.app.deeplink.DeepLinkParser class MainActivity : ComponentActivity() { private var deepLinkUri by mutableStateOf(null) private val shareManager: ShareManager by inject() override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() enableEdgeToEdge() // Register activity result launcher for file picker (must be before STARTED) (shareManager as? AndroidShareManager)?.registerActivityResultLauncher(this) super.onCreate(savedInstanceState) handleIncomingIntent(intent) setContent { DisposableEffect(Unit) { val listener = Consumer { newIntent -> handleIncomingIntent(newIntent) } addOnNewIntentListener(listener) onDispose { removeOnNewIntentListener(listener) } } App(deepLinkUri = deepLinkUri) } } private fun handleIncomingIntent(intent: Intent?) { if (intent == null) return val uriString = when (intent.action) { Intent.ACTION_VIEW -> { intent.data?.toString() } Intent.ACTION_SEND -> { val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT) sharedText?.let { DeepLinkParser.extractSupportedUrl(it) } } else -> { null } } uriString?.let { deepLinkUri = it } } } @Preview @Composable fun AppAndroidPreview() { App() } ================================================ FILE: composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt ================================================ package zed.rainxch.githubstore.app import android.app.Application import android.app.NotificationChannel import android.app.NotificationManager import android.os.Build import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import org.koin.android.ext.android.get import org.koin.android.ext.koin.androidContext import zed.rainxch.core.data.services.PackageEventReceiver import zed.rainxch.core.data.services.UpdateScheduler import zed.rainxch.core.domain.model.InstallSource import zed.rainxch.core.domain.model.InstalledApp import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.repository.TweaksRepository import zed.rainxch.core.domain.system.PackageMonitor import zed.rainxch.githubstore.app.di.initKoin class GithubStoreApp : Application() { private var packageEventReceiver: PackageEventReceiver? = null private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) override fun onCreate() { super.onCreate() initKoin { androidContext(this@GithubStoreApp) } createNotificationChannels() registerPackageEventReceiver() scheduleBackgroundUpdateChecks() registerSelfAsInstalledApp() } private fun createNotificationChannels() { val notificationManager = getSystemService(NotificationManager::class.java) val updatesChannel = NotificationChannel( UPDATES_CHANNEL_ID, "App Updates", NotificationManager.IMPORTANCE_HIGH, ).apply { description = "Notifications when app updates are available" } notificationManager.createNotificationChannel(updatesChannel) val serviceChannel = NotificationChannel( UPDATE_SERVICE_CHANNEL_ID, "Update Service", NotificationManager.IMPORTANCE_LOW, ).apply { description = "Background update check and auto-update progress" setShowBadge(false) } notificationManager.createNotificationChannel(serviceChannel) } private fun registerPackageEventReceiver() { val receiver = PackageEventReceiver( installedAppsRepository = get(), packageMonitor = get(), ) val filter = PackageEventReceiver.createIntentFilter() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { registerReceiver(receiver, filter, RECEIVER_NOT_EXPORTED) } else { registerReceiver(receiver, filter) } packageEventReceiver = receiver } private fun scheduleBackgroundUpdateChecks() { appScope.launch { try { val intervalHours = get().getUpdateCheckInterval().first() UpdateScheduler.schedule( context = this@GithubStoreApp, intervalHours = intervalHours, ) } catch (e: Exception) { Logger.e(e) { "Failed to schedule background update checks" } } } } private fun registerSelfAsInstalledApp() { appScope.launch { try { val repo = get() val selfPackageName = packageName val existing = repo.getAppByPackage(selfPackageName) if (existing != null) return@launch val packageMonitor = get() val systemInfo = packageMonitor.getInstalledPackageInfo(selfPackageName) if (systemInfo == null) { Logger.w { "GithubStoreApp: Skip self-registration, package info missing for $selfPackageName" } return@launch } val now = System.currentTimeMillis() val versionName = systemInfo.versionName val versionCode = systemInfo.versionCode val selfApp = InstalledApp( packageName = selfPackageName, repoId = SELF_REPO_ID, repoName = SELF_REPO_NAME, repoOwner = SELF_REPO_OWNER, repoOwnerAvatarUrl = SELF_AVATAR_URL, repoDescription = "A cross-platform app store for GitHub releases", primaryLanguage = "Kotlin", repoUrl = "https://github.com/$SELF_REPO_OWNER/$SELF_REPO_NAME", installedVersion = versionName, installedAssetName = null, installedAssetUrl = null, latestVersion = null, latestAssetName = null, latestAssetUrl = null, latestAssetSize = null, appName = "GitHub Store", installSource = InstallSource.THIS_APP, installedAt = now, lastCheckedAt = 0L, lastUpdatedAt = now, isUpdateAvailable = false, updateCheckEnabled = true, releaseNotes = null, systemArchitecture = "", fileExtension = "apk", isPendingInstall = false, installedVersionName = versionName, installedVersionCode = versionCode, signingFingerprint = SELF_SHA256_FINGERPRINT, ) repo.saveInstalledApp(selfApp) Logger.i("GitHub Store App: App added") } catch (e: Exception) { Logger.e(e) { "GitHub Store App: Failed to register self as installed app" } } } } companion object { private const val SELF_REPO_ID = 1101281251L private const val SELF_SHA256_FINGERPRINT = @Suppress("ktlint:standard:max-line-length") "B7:F2:8E:19:8E:48:C1:93:B0:38:C6:5D:92:DD:F7:BC:07:7B:0D:B5:9E:BC:9B:25:0A:6D:AC:48:C1:18:03:CA" private const val SELF_REPO_OWNER = "OpenHub-Store" private const val SELF_REPO_NAME = "GitHub-Store" private const val SELF_AVATAR_URL = @Suppress("ktlint:standard:max-line-length") "https://raw.githubusercontent.com/OpenHub-Store/GitHub-Store/refs/heads/main/media-resources/app_icon.png" const val UPDATES_CHANNEL_ID = "app_updates" const val UPDATE_SERVICE_CHANNEL_ID = "update_service" } } ================================================ FILE: composeApp/src/androidMain/res/drawable/ic_launcher_monochrome.xml ================================================ ================================================ FILE: composeApp/src/androidMain/res/drawable/ic_splash.xml ================================================ ================================================ FILE: composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml ================================================ ================================================ FILE: composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml ================================================ ================================================ FILE: composeApp/src/androidMain/res/values/colors.xml ================================================ #101010 ================================================ FILE: composeApp/src/androidMain/res/values/splash.xml ================================================ ================================================ FILE: composeApp/src/androidMain/res/values/strings.xml ================================================ GitHub Store ================================================ FILE: composeApp/src/androidMain/res/xml/filepaths.xml ================================================ ================================================ FILE: composeApp/src/androidMain/res/xml/network_security_config.xml ================================================ github.com api.github.com ================================================ FILE: composeApp/src/commonMain/composeResources/drawable/ic_github.xml ================================================ ================================================ FILE: composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt ================================================ package zed.rainxch.githubstore import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import org.koin.compose.viewmodel.koinViewModel import zed.rainxch.core.presentation.theme.GithubStoreTheme import zed.rainxch.core.presentation.utils.ApplyAndroidSystemBars import zed.rainxch.core.presentation.utils.ObserveAsEvents import zed.rainxch.githubstore.app.components.RateLimitDialog import zed.rainxch.githubstore.app.components.SessionExpiredDialog import zed.rainxch.githubstore.app.deeplink.DeepLinkDestination import zed.rainxch.githubstore.app.deeplink.DeepLinkParser import zed.rainxch.githubstore.app.desktop.KeyboardNavigation import zed.rainxch.githubstore.app.desktop.KeyboardNavigationEvent import zed.rainxch.githubstore.app.navigation.AppNavigation import zed.rainxch.githubstore.app.navigation.GithubStoreGraph import zed.rainxch.githubstore.app.navigation.getCurrentScreen @Composable fun App(deepLinkUri: String? = null) { val viewModel: MainViewModel = koinViewModel() val state by viewModel.state.collectAsStateWithLifecycle() val navController = rememberNavController() val currentScreen = navController.currentBackStackEntryAsState().value.getCurrentScreen() LaunchedEffect(deepLinkUri) { deepLinkUri?.let { uri -> when (val destination = DeepLinkParser.parse(uri)) { is DeepLinkDestination.Repository -> { navController.navigate( GithubStoreGraph.DetailsScreen( owner = destination.owner, repo = destination.repo, ), ) } DeepLinkDestination.None -> { // ignore unrecognized deep links } } } } ObserveAsEvents(KeyboardNavigation.events) { event -> when (event) { KeyboardNavigationEvent.OnCtrlFClick -> { if (currentScreen !is GithubStoreGraph.SearchScreen) { navController.navigate(GithubStoreGraph.SearchScreen) { popUpTo(GithubStoreGraph.HomeScreen) { saveState = true } launchSingleTop = true restoreState = true } } } } } GithubStoreTheme( fontTheme = state.currentFontTheme, appTheme = state.currentColorTheme, isAmoledTheme = state.isAmoledTheme, isDarkTheme = state.isDarkTheme ?: isSystemInDarkTheme(), ) { ApplyAndroidSystemBars(state.isDarkTheme) if (state.showRateLimitDialog && state.rateLimitInfo != null) { RateLimitDialog( rateLimitInfo = state.rateLimitInfo!!, isAuthenticated = state.isLoggedIn, onDismiss = { viewModel.onAction(MainAction.DismissRateLimitDialog) }, onSignIn = { viewModel.onAction(MainAction.DismissRateLimitDialog) navController.navigate(GithubStoreGraph.AuthenticationScreen) }, ) } if (state.showSessionExpiredDialog) { SessionExpiredDialog( onDismiss = { viewModel.onAction(MainAction.DismissSessionExpiredDialog) }, onSignIn = { viewModel.onAction(MainAction.DismissSessionExpiredDialog) navController.navigate(GithubStoreGraph.AuthenticationScreen) }, ) } AppNavigation( navController = navController, isLiquidGlassEnabled = state.isLiquidGlassEnabled, ) } } ================================================ FILE: composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/MainAction.kt ================================================ package zed.rainxch.githubstore sealed interface MainAction { data object DismissRateLimitDialog : MainAction data object DismissSessionExpiredDialog : MainAction } ================================================ FILE: composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/MainState.kt ================================================ package zed.rainxch.githubstore import zed.rainxch.core.domain.model.AppTheme import zed.rainxch.core.domain.model.FontTheme import zed.rainxch.core.domain.model.RateLimitInfo data class MainState( val isLoggedIn: Boolean = false, val rateLimitInfo: RateLimitInfo? = null, val showRateLimitDialog: Boolean = false, val showSessionExpiredDialog: Boolean = false, val currentColorTheme: AppTheme = AppTheme.OCEAN, val isAmoledTheme: Boolean = false, val isDarkTheme: Boolean? = null, val currentFontTheme: FontTheme = FontTheme.CUSTOM, val isLiquidGlassEnabled: Boolean = true, ) ================================================ FILE: composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/MainViewModel.kt ================================================ package zed.rainxch.githubstore import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import zed.rainxch.core.domain.repository.AuthenticationState import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.repository.RateLimitRepository import zed.rainxch.core.domain.repository.TweaksRepository import zed.rainxch.core.domain.use_cases.SyncInstalledAppsUseCase class MainViewModel( private val tweaksRepository: TweaksRepository, private val installedAppsRepository: InstalledAppsRepository, private val authenticationState: AuthenticationState, private val rateLimitRepository: RateLimitRepository, private val syncUseCase: SyncInstalledAppsUseCase, ) : ViewModel() { private val _state = MutableStateFlow(MainState()) val state = _state.asStateFlow() init { viewModelScope.launch(Dispatchers.IO) { authenticationState .isUserLoggedIn() .collect { isLoggedIn -> _state.update { it.copy(isLoggedIn = isLoggedIn) } if (isLoggedIn) { rateLimitRepository.clear() } } } viewModelScope.launch { tweaksRepository .getThemeColor() .collect { theme -> _state.update { it.copy(currentColorTheme = theme) } } } viewModelScope.launch { tweaksRepository .getAmoledTheme() .collect { isAmoled -> _state.update { it.copy(isAmoledTheme = isAmoled) } } } viewModelScope.launch { tweaksRepository .getIsDarkTheme() .collect { isDarkTheme -> _state.update { it.copy(isDarkTheme = isDarkTheme) } } } viewModelScope.launch { tweaksRepository .getFontTheme() .collect { fontTheme -> _state.update { it.copy(currentFontTheme = fontTheme) } } } viewModelScope.launch { tweaksRepository.getLiquidGlassEnabled().collect { enabled -> _state.update { it.copy(isLiquidGlassEnabled = enabled) } } } viewModelScope.launch { rateLimitRepository.rateLimitState.collect { rateLimitInfo -> _state.update { currentState -> currentState.copy(rateLimitInfo = rateLimitInfo) } } } viewModelScope.launch { rateLimitRepository.rateLimitExhaustedEvent.collect { info -> _state.update { it.copy(showRateLimitDialog = true, rateLimitInfo = info) } } } viewModelScope.launch { authenticationState.sessionExpiredEvent.collect { _state.update { it.copy(showSessionExpiredDialog = true) } } } viewModelScope.launch(Dispatchers.IO) { syncUseCase().onSuccess { installedAppsRepository.checkAllForUpdates() } } } fun onAction(action: MainAction) { when (action) { MainAction.DismissRateLimitDialog -> { _state.update { it.copy(showRateLimitDialog = false) } } MainAction.DismissSessionExpiredDialog -> { _state.update { it.copy(showSessionExpiredDialog = false) } } } } } ================================================ FILE: composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/components/RateLimitDialog.kt ================================================ package zed.rainxch.githubstore.app.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import zed.rainxch.core.domain.model.RateLimitInfo import zed.rainxch.core.presentation.theme.GithubStoreTheme import zed.rainxch.githubstore.core.presentation.res.Res import zed.rainxch.githubstore.core.presentation.res.rate_limit_close import zed.rainxch.githubstore.core.presentation.res.rate_limit_exceeded import zed.rainxch.githubstore.core.presentation.res.rate_limit_ok import zed.rainxch.githubstore.core.presentation.res.rate_limit_resets_in_minutes import zed.rainxch.githubstore.core.presentation.res.rate_limit_sign_in import zed.rainxch.githubstore.core.presentation.res.rate_limit_tip_sign_in import zed.rainxch.githubstore.core.presentation.res.rate_limit_used_all import zed.rainxch.githubstore.core.presentation.res.rate_limit_used_all_free @Composable fun RateLimitDialog( rateLimitInfo: RateLimitInfo, isAuthenticated: Boolean, onDismiss: () -> Unit, onSignIn: () -> Unit, ) { val timeUntilReset = remember(rateLimitInfo) { rateLimitInfo.timeUntilReset().inWholeMinutes.toInt() } AlertDialog( onDismissRequest = onDismiss, icon = { Icon( imageVector = Icons.Default.Warning, contentDescription = null, tint = MaterialTheme.colorScheme.error, ) }, title = { Text( text = stringResource(Res.string.rate_limit_exceeded), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Black, color = MaterialTheme.colorScheme.onSurface, ) }, text = { Column( verticalArrangement = Arrangement.spacedBy(8.dp), ) { Text( text = if (isAuthenticated) { stringResource( Res.string.rate_limit_used_all, rateLimitInfo.limit, ) } else { stringResource( Res.string.rate_limit_used_all_free, 60, ) }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.outline, ) Text( text = stringResource( Res.string.rate_limit_resets_in_minutes, timeUntilReset, ), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface, ) if (!isAuthenticated) { Spacer(modifier = Modifier.height(8.dp)) Text( text = stringResource(Res.string.rate_limit_tip_sign_in), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary, ) } } }, confirmButton = { if (!isAuthenticated) { Button(onClick = onSignIn) { Text( text = stringResource(Res.string.rate_limit_sign_in), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onPrimary, ) } } else { Button(onClick = onDismiss) { Text( text = stringResource(Res.string.rate_limit_ok), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface, ) } } }, dismissButton = { TextButton(onClick = onDismiss) { Text( text = stringResource(Res.string.rate_limit_close), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface, ) } }, ) } @Preview @Composable fun RateLimitDialogPreview() { GithubStoreTheme { RateLimitDialog( rateLimitInfo = RateLimitInfo( limit = 1000, remaining = 2000, resetTimestamp = 0L, ), isAuthenticated = false, onDismiss = { }, onSignIn = { }, ) } } ================================================ FILE: composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/components/SessionExpiredDialog.kt ================================================ package zed.rainxch.githubstore.app.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.LockOpen import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import zed.rainxch.githubstore.core.presentation.res.* @Composable fun SessionExpiredDialog( onDismiss: () -> Unit, onSignIn: () -> Unit, ) { AlertDialog( onDismissRequest = onDismiss, icon = { Icon( imageVector = Icons.Default.LockOpen, contentDescription = null, tint = MaterialTheme.colorScheme.error, ) }, title = { Text( text = stringResource(Res.string.session_expired_title), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Black, color = MaterialTheme.colorScheme.onSurface, ) }, text = { Column( verticalArrangement = Arrangement.spacedBy(8.dp), ) { Text( text = stringResource(Res.string.session_expired_message), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.outline, ) Text( text = stringResource(Res.string.session_expired_hint), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary, ) } }, confirmButton = { Button(onClick = onSignIn) { Text( text = stringResource(Res.string.sign_in_again), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onPrimary, ) } }, dismissButton = { TextButton(onClick = onDismiss) { Text( text = stringResource(Res.string.continue_as_guest), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface, ) } }, ) } ================================================ FILE: composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/deeplink/DeepLinkParser.kt ================================================ package zed.rainxch.githubstore.app.deeplink sealed interface DeepLinkDestination { data class Repository( val owner: String, val repo: String, ) : DeepLinkDestination data object None : DeepLinkDestination } object DeepLinkParser { private val INVALID_CHARS = setOf('/', '\\', '?', '#', '@', ':', '*', '"', '<', '>', '|', '%', '&', '=') private val FORBIDDEN_PATTERNS = listOf("..", "~", "\u0000") private val EXCLUDED_PATHS = setOf( "about", "account", "admin", "api", "apps", "articles", "blog", "business", "collections", "contact", "dashboard", "enterprises", "events", "explore", "features", "home", "issues", "marketplace", "new", "notifications", "orgs", "pricing", "pulls", "search", "security", "settings", "showcases", "site", "sponsors", "topics", "trending", "team", ) fun parse(uri: String): DeepLinkDestination { return when { uri.startsWith("githubstore://repo/") -> { val path = uri.removePrefix("githubstore://repo/") val decoded = urlDecode(path) parseOwnerRepo(decoded) } uri.startsWith("https://github.com/") -> { val path = uri .removePrefix("https://github.com/") .substringBefore('?') .substringBefore('#') val decoded = urlDecode(path) val parts = decoded.split("/").filter { it.isNotEmpty() } if (parts.size >= 2) { val owner = parts[0] val repo = parts[1] if (isStrictlyValidOwnerRepo(owner, repo)) { return DeepLinkDestination.Repository(owner, repo) } } DeepLinkDestination.None } uri.startsWith("https://github-store.org/app/") -> { extractQueryParam(uri, "repo")?.let { encodedRepoParam -> val decoded = urlDecode(encodedRepoParam) parseOwnerRepo(decoded) } ?: DeepLinkDestination.None } else -> { DeepLinkDestination.None } } } /** * URL-decode a string, handling percent-encoded characters. * Returns the original string if decoding fails. */ private fun urlDecode(value: String): String = try { val result = StringBuilder() var i = 0 while (i < value.length) { when (val c = value[i]) { '%' -> { if (i + 2 < value.length) { val hex = value.substring(i + 1, i + 3) val code = hex.toIntOrNull(16) if (code != null) { result.append(code.toChar()) i += 3 continue } } result.append(c) i++ } '+' -> { result.append(' ') i++ } else -> { result.append(c) i++ } } } result.toString() } catch (_: Exception) { value } private fun parseOwnerRepo(path: String): DeepLinkDestination { val parts = path.split("/").filter { it.isNotEmpty() } return if (parts.size >= 2) { val owner = parts[0] val repo = parts[1] if (isStrictlyValidOwnerRepo(owner, repo)) { DeepLinkDestination.Repository(owner, repo) } else { DeepLinkDestination.None } } else { DeepLinkDestination.None } } /** * Strictly validate owner and repo names to prevent injection attacks. * Rejects: * - Empty strings * - Special characters that could be used for injection * - Path traversal patterns * - Control characters and whitespace * - Excluded GitHub paths (like 'about', 'settings', etc.) * - Names that exceed GitHub's length limits * - Names that don't start with alphanumeric characters */ private fun isStrictlyValidOwnerRepo( owner: String, repo: String, ): Boolean { if (owner.isEmpty() || repo.isEmpty()) { return false } if (owner.any { it in INVALID_CHARS } || repo.any { it in INVALID_CHARS }) { return false } if (FORBIDDEN_PATTERNS.any { pattern -> owner.contains(pattern, ignoreCase = true) || repo.contains(pattern, ignoreCase = true) } ) { return false } if (owner.any { it.isISOControl() } || repo.any { it.isISOControl() }) { return false } if (owner.contains(' ') || repo.contains(' ')) { return false } if (EXCLUDED_PATHS.contains(owner.lowercase())) { return false } if (owner.length > 39 || repo.length > 100) { return false } if (!owner.first().isLetterOrDigit() || !repo.first().isLetterOrDigit()) { return false } return true } private fun extractQueryParam( uri: String, key: String, ): String? { val queryStart = uri.indexOf('?') if (queryStart == -1) return null val queryString = uri.substring(queryStart + 1) val params = queryString.split('&') for (param in params) { val keyValue = param.split('=', limit = 2) if (keyValue.size == 2 && keyValue[0] == key) { return keyValue[1] } } return null } fun extractSupportedUrl(text: String): String? { val regex = """https?://(?:www\.)?(?:github\.com|github-store\.org)(?=[/\s?#]|$)[^\s<>"')\],;.!]*""".toRegex( RegexOption.IGNORE_CASE, ) return regex.find(text)?.value } } ================================================ FILE: composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/desktop/KeyboardNavigation.kt ================================================ package zed.rainxch.githubstore.app.desktop import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.receiveAsFlow object KeyboardNavigation { private val _events = Channel() val events = _events.receiveAsFlow() fun onKeyClicked(event: KeyboardNavigationEvent) { _events.trySend(event) } } ================================================ FILE: composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/desktop/KeyboardNavigationEvent.kt ================================================ package zed.rainxch.githubstore.app.desktop sealed interface KeyboardNavigationEvent { data object OnCtrlFClick : KeyboardNavigationEvent } ================================================ FILE: composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/SharedModules.kt ================================================ package zed.rainxch.githubstore.app.di import org.koin.core.module.Module import org.koin.core.module.dsl.viewModel import org.koin.dsl.module import zed.rainxch.githubstore.MainViewModel val mainModule: Module = module { viewModel { MainViewModel( tweaksRepository = get(), installedAppsRepository = get(), rateLimitRepository = get(), syncUseCase = get(), authenticationState = get(), ) } } ================================================ FILE: composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/ViewModelsModule.kt ================================================ package zed.rainxch.githubstore.app.di import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.module import zed.rainxch.apps.presentation.AppsViewModel import zed.rainxch.auth.presentation.AuthenticationViewModel import zed.rainxch.details.presentation.DetailsViewModel import zed.rainxch.devprofile.presentation.DeveloperProfileViewModel import zed.rainxch.favourites.presentation.FavouritesViewModel import zed.rainxch.home.presentation.HomeViewModel import zed.rainxch.profile.presentation.ProfileViewModel import zed.rainxch.search.presentation.SearchViewModel import zed.rainxch.starred.presentation.StarredReposViewModel val viewModelsModule = module { viewModelOf(::AppsViewModel) viewModelOf(::AuthenticationViewModel) viewModelOf(::DetailsViewModel) viewModelOf(::DeveloperProfileViewModel) viewModelOf(::FavouritesViewModel) viewModelOf(::HomeViewModel) viewModelOf(::SearchViewModel) viewModelOf(::ProfileViewModel) viewModelOf(::StarredReposViewModel) } ================================================ FILE: composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/initKoin.kt ================================================ package zed.rainxch.githubstore.app.di import org.koin.core.context.startKoin import org.koin.dsl.KoinAppDeclaration import zed.rainxch.apps.data.di.appsModule import zed.rainxch.auth.data.di.authModule import zed.rainxch.core.data.di.coreModule import zed.rainxch.core.data.di.corePlatformModule import zed.rainxch.core.data.di.databaseModule import zed.rainxch.core.data.di.networkModule import zed.rainxch.details.data.di.detailsModule import zed.rainxch.devprofile.data.di.devProfileModule import zed.rainxch.home.data.di.homeModule import zed.rainxch.profile.data.di.settingsModule import zed.rainxch.search.data.di.searchModule fun initKoin(config: KoinAppDeclaration? = null) { startKoin { config?.invoke(this) modules( mainModule, corePlatformModule, coreModule, networkModule, databaseModule, viewModelsModule, appsModule, authModule, detailsModule, devProfileModule, homeModule, searchModule, settingsModule, ) } } ================================================ FILE: composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt ================================================ package zed.rainxch.githubstore.app.navigation import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider 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.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.toRoute import io.github.fletchmckee.liquid.rememberLiquidState import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf import zed.rainxch.apps.presentation.AppsRoot import zed.rainxch.apps.presentation.AppsViewModel import zed.rainxch.auth.presentation.AuthenticationRoot import zed.rainxch.core.presentation.locals.LocalBottomNavigationHeight import zed.rainxch.core.presentation.locals.LocalBottomNavigationLiquid import zed.rainxch.details.presentation.DetailsRoot import zed.rainxch.devprofile.presentation.DeveloperProfileRoot import zed.rainxch.favourites.presentation.FavouritesRoot import zed.rainxch.home.presentation.HomeRoot import zed.rainxch.profile.presentation.ProfileRoot import zed.rainxch.search.presentation.SearchRoot import zed.rainxch.starred.presentation.StarredReposRoot @Composable fun AppNavigation( navController: NavHostController, isLiquidGlassEnabled: Boolean = true, ) { val liquidState = rememberLiquidState() var bottomNavigationHeight by remember { mutableStateOf(0.dp) } val density = LocalDensity.current val appsViewModel = koinViewModel() val appsState by appsViewModel.state.collectAsStateWithLifecycle() CompositionLocalProvider( LocalBottomNavigationLiquid provides liquidState, LocalBottomNavigationHeight provides bottomNavigationHeight, ) { Box( modifier = Modifier.fillMaxSize(), ) { NavHost( navController = navController, startDestination = GithubStoreGraph.HomeScreen, modifier = Modifier.background(MaterialTheme.colorScheme.background), ) { composable { HomeRoot( onNavigateToSearch = { navController.navigate(GithubStoreGraph.SearchScreen) }, onNavigateToSettings = { navController.navigate(GithubStoreGraph.ProfileScreen) }, onNavigateToApps = { navController.navigate(GithubStoreGraph.AppsScreen) }, onNavigateToDetails = { repoId -> navController.navigate( GithubStoreGraph.DetailsScreen( repositoryId = repoId, ), ) }, onNavigateToDeveloperProfile = { username -> navController.navigate( GithubStoreGraph.DeveloperProfileScreen( username = username, ), ) }, ) } composable { SearchRoot( onNavigateBack = { navController.navigateUp() }, onNavigateToDetails = { repoId -> navController.navigate( GithubStoreGraph.DetailsScreen( repositoryId = repoId, ), ) }, onNavigateToDetailsFromLink = { owner, repo -> navController.navigate( GithubStoreGraph.DetailsScreen( owner = owner, repo = repo, ), ) }, onNavigateToDeveloperProfile = { username -> navController.navigate( GithubStoreGraph.DeveloperProfileScreen( username = username, ), ) }, ) } composable { backStackEntry -> val args = backStackEntry.toRoute() DetailsRoot( onNavigateBack = { navController.navigateUp() }, onOpenRepositoryInApp = { repoId -> navController.navigate( GithubStoreGraph.DetailsScreen( repositoryId = repoId, ), ) }, onNavigateToDeveloperProfile = { username -> navController.navigate( GithubStoreGraph.DeveloperProfileScreen( username = username, ), ) }, viewModel = koinViewModel { parametersOf( args.repositoryId, args.owner, args.repo, args.isComingFromUpdate, ) }, ) } composable { backStackEntry -> val args = backStackEntry.toRoute() DeveloperProfileRoot( onNavigateBack = { navController.navigateUp() }, onNavigateToDetails = { repoId -> navController.navigate( GithubStoreGraph.DetailsScreen( repositoryId = repoId, ), ) }, viewModel = koinViewModel { parametersOf(args.username) }, ) } composable { AuthenticationRoot( onNavigateToHome = { navController.navigate(GithubStoreGraph.HomeScreen) { popUpTo(0) { inclusive = true } } }, ) } composable { FavouritesRoot( onNavigateBack = { navController.navigateUp() }, onNavigateToDetails = { navController.navigate(GithubStoreGraph.DetailsScreen(it)) }, onNavigateToDeveloperProfile = { username -> navController.navigate( GithubStoreGraph.DeveloperProfileScreen( username = username, ), ) }, ) } composable { StarredReposRoot( onNavigateBack = { navController.navigateUp() }, onNavigateToDetails = { repoId -> navController.navigate( GithubStoreGraph.DetailsScreen( repositoryId = repoId, ), ) }, onNavigateToAuthentication = { navController.navigate( GithubStoreGraph.AuthenticationScreen, ) }, onNavigateToDeveloperProfile = { username -> navController.navigate( GithubStoreGraph.DeveloperProfileScreen( username = username, ), ) }, ) } composable { ProfileRoot( onNavigateBack = { navController.navigateUp() }, onNavigateToAuthentication = { navController.navigate(GithubStoreGraph.AuthenticationScreen) }, onNavigateToStarredRepos = { navController.navigate(GithubStoreGraph.StarredReposScreen) }, onNavigateToFavouriteRepos = { navController.navigate(GithubStoreGraph.FavouritesScreen) }, onNavigateToDevProfile = { username -> navController.navigate(GithubStoreGraph.DeveloperProfileScreen(username)) }, onNavigateToSponsor = { navController.navigate(GithubStoreGraph.SponsorScreen) }, ) } composable { zed.rainxch.profile.presentation.SponsorScreen( onNavigateBack = { navController.navigateUp() }, ) } composable { AppsRoot( onNavigateBack = { navController.navigateUp() }, onNavigateToRepo = { repoId -> navController.navigate( GithubStoreGraph.DetailsScreen( repositoryId = repoId, isComingFromUpdate = true, ), ) }, viewModel = appsViewModel, state = appsState, ) } } val currentScreen = navController.currentBackStackEntryAsState().value.getCurrentScreen() currentScreen?.let { BottomNavigation( currentScreen = currentScreen, onNavigate = { navController.navigate(it) { popUpTo(GithubStoreGraph.HomeScreen) { saveState = true } launchSingleTop = true restoreState = true } }, isUpdateAvailable = appsState.apps.any { it.installedApp.isUpdateAvailable }, isLiquidGlassEnabled = isLiquidGlassEnabled, modifier = Modifier .align(Alignment.BottomCenter) .navigationBarsPadding() .padding(bottom = 24.dp) .onGloballyPositioned { coordinates -> bottomNavigationHeight = with(density) { coordinates.size.height.toDp() } }, ) } } } } ================================================ FILE: composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/BottomNavigation.kt ================================================ package zed.rainxch.githubstore.app.navigation import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween 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.collectIsPressedAsState 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.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text 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.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.draw.drawBehind import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.luminance import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInParent import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import io.github.fletchmckee.liquid.liquid import io.github.fletchmckee.liquid.rememberLiquidState import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import zed.rainxch.core.domain.getPlatform import zed.rainxch.core.domain.model.Platform import zed.rainxch.core.presentation.locals.LocalBottomNavigationLiquid import zed.rainxch.core.presentation.theme.GithubStoreTheme import zed.rainxch.core.presentation.utils.isLiquidFrostAvailable @Composable fun BottomNavigation( currentScreen: GithubStoreGraph, onNavigate: (GithubStoreGraph) -> Unit, isUpdateAvailable: Boolean, isLiquidGlassEnabled: Boolean = true, modifier: Modifier = Modifier, ) { val liquidState = LocalBottomNavigationLiquid.current if (currentScreen !in BottomNavigationUtils.allowedScreens()) return val visibleItems = remember { BottomNavigationUtils.items().filterNot { getPlatform() != Platform.ANDROID && it.screen == GithubStoreGraph.AppsScreen } } val selectedIndex = visibleItems.indexOfFirst { it.screen == currentScreen } val itemPositions = remember { mutableMapOf>() } var selectedItemPos by remember { mutableStateOf?>(null) } val rowHorizontalPaddingDp = 6.dp val density = LocalDensity.current val rowHorizontalPaddingPx = with(density) { rowHorizontalPaddingDp.toPx() } val indicatorHorizontalInsetPx = with(density) { 4.dp.toPx() } val indicatorX = remember { Animatable(0f) } val indicatorWidth = remember { Animatable(0f) } LaunchedEffect(selectedIndex, selectedItemPos) { val raw = selectedItemPos ?: itemPositions[selectedIndex] ?: return@LaunchedEffect val targetX = raw.first + rowHorizontalPaddingPx - indicatorHorizontalInsetPx val targetW = raw.second + indicatorHorizontalInsetPx * 2f launch { indicatorX.animateTo( targetValue = targetX, animationSpec = spring( dampingRatio = Spring.DampingRatioLowBouncy, stiffness = Spring.StiffnessLow, ), ) } launch { indicatorWidth.animateTo( targetValue = targetW, animationSpec = spring( dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMedium, ), ) } } val isDarkTheme = !MaterialTheme.colorScheme.background .luminance() .let { it > 0.5f } Box( modifier = modifier, contentAlignment = Alignment.Center, ) { val useLiquid = isLiquidGlassEnabled && isLiquidFrostAvailable() Box( modifier = Modifier .clip(CircleShape) .then( if (useLiquid) { Modifier .background( MaterialTheme.colorScheme.surfaceContainerHighest.copy( alpha = if (isDarkTheme) .25f else .15f, ), ).liquid(liquidState) { this.shape = CircleShape this.frost = if (isDarkTheme) 12.dp else 10.dp this.curve = if (isDarkTheme) .35f else .45f this.refraction = if (isDarkTheme) .08f else .12f this.dispersion = if (isDarkTheme) .18f else .25f this.saturation = if (isDarkTheme) .40f else .55f this.contrast = if (isDarkTheme) 1.8f else 1.6f } } else { Modifier .background(MaterialTheme.colorScheme.surfaceContainer) .border( width = 1.dp, color = MaterialTheme.colorScheme.outlineVariant, shape = CircleShape, ) }, ).pointerInput(Unit) { }, ) { val glassHighColor = if (isDarkTheme) { Color.White.copy(alpha = .12f) } else { Color.White.copy(alpha = .30f) } val glassLowColor = if (isDarkTheme) { Color.White.copy(alpha = .04f) } else { Color.White.copy(alpha = .10f) } val specularColor = if (isDarkTheme) { Color.White.copy(alpha = .18f) } else { Color.White.copy(alpha = .45f) } val innerGlowColor = if (isDarkTheme) { Color.White.copy(alpha = .03f) } else { Color.White.copy(alpha = .08f) } val borderColor = if (isDarkTheme) { Color.White.copy(alpha = .08f) } else { Color.Transparent } Box( modifier = Modifier .matchParentSize() .drawBehind { if (indicatorWidth.value > 0f) { if (isDarkTheme) { drawRoundRect( color = borderColor, topLeft = Offset( indicatorX.value - .5.dp.toPx(), 1.5.dp.toPx(), ), size = Size( indicatorWidth.value + 1.dp.toPx(), size.height - 3.dp.toPx(), ), cornerRadius = CornerRadius(size.height / 2f), style = Stroke(width = 1.dp.toPx()), ) } drawRoundRect( brush = Brush.verticalGradient( colors = listOf(glassHighColor, glassLowColor), ), topLeft = Offset(indicatorX.value, 2.dp.toPx()), size = Size(indicatorWidth.value, size.height - 4.dp.toPx()), cornerRadius = CornerRadius(size.height / 2f), ) drawRoundRect( brush = Brush.horizontalGradient( colors = listOf( Color.Transparent, specularColor, Color.Transparent, ), startX = indicatorX.value + indicatorWidth.value * .15f, endX = indicatorX.value + indicatorWidth.value * .85f, ), topLeft = Offset( indicatorX.value + indicatorWidth.value * .15f, 3.dp.toPx(), ), size = Size(indicatorWidth.value * .7f, 1.5.dp.toPx()), cornerRadius = CornerRadius(1.dp.toPx()), ) drawRoundRect( brush = Brush.verticalGradient( colors = listOf(Color.Transparent, innerGlowColor), ), topLeft = Offset( indicatorX.value + 4.dp.toPx(), size.height - 8.dp.toPx(), ), size = Size(indicatorWidth.value - 8.dp.toPx(), 4.dp.toPx()), cornerRadius = CornerRadius(2.dp.toPx()), ) } }, ) Row( modifier = Modifier.padding(horizontal = rowHorizontalPaddingDp, vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically, ) { visibleItems.forEachIndexed { index, item -> LiquidGlassTabItem( item = item, hasBadge = item.screen == GithubStoreGraph.AppsScreen && isUpdateAvailable, isSelected = item.screen == currentScreen, onSelect = { onNavigate(item.screen) }, onPositioned = { x, width -> itemPositions[index] = x to width if (index == selectedIndex) { selectedItemPos = x to width } if (index == selectedIndex && indicatorWidth.value == 0f) { val snapX = x + rowHorizontalPaddingPx - indicatorHorizontalInsetPx val snapW = width + indicatorHorizontalInsetPx * 2f indicatorX.snapTo(snapX) indicatorWidth.snapTo(snapW) } }, ) } } } } } @Composable private fun LiquidGlassTabItem( item: BottomNavigationItem, isSelected: Boolean, onSelect: () -> Unit, hasBadge: Boolean = false, onPositioned: suspend (x: Float, width: Float) -> Unit, ) { val scope = rememberCoroutineScope() val density = LocalDensity.current val interactionSource = remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() val pressScale by animateFloatAsState( targetValue = if (isPressed) 0.85f else 1f, animationSpec = spring( dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMedium, ), label = "pressScale", ) val iconScale by animateFloatAsState( targetValue = if (isSelected) 1.15f else 1f, animationSpec = spring( dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow, ), label = "iconScale", ) val iconOffsetY by animateDpAsState( targetValue = if (isSelected) (-1).dp else 1.dp, animationSpec = spring( dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow, ), label = "iconOffsetY", ) val iconTint = if (isSelected) { MaterialTheme.colorScheme.onPrimaryContainer } else { MaterialTheme.colorScheme.onSurface.copy(alpha = .7f) } val labelAlpha by animateFloatAsState( targetValue = if (isSelected) 1f else 0f, animationSpec = tween( durationMillis = if (isSelected) 250 else 150, easing = FastOutSlowInEasing, ), label = "labelAlpha", ) val labelScale by animateFloatAsState( targetValue = if (isSelected) 1f else 0.6f, animationSpec = spring( dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow, ), label = "labelScale", ) val horizontalPadding by animateDpAsState( targetValue = if (isSelected) 20.dp else 14.dp, animationSpec = spring( dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow, ), label = "hPadding", ) Box( modifier = Modifier .clip(CircleShape) .clickable( interactionSource = interactionSource, indication = null, ) { onSelect() } .onGloballyPositioned { coordinates -> val x = coordinates.positionInParent().x val width = coordinates.size.width.toFloat() scope.launch { onPositioned(x, width) } }.graphicsLayer { scaleX = pressScale scaleY = pressScale }.padding(horizontal = horizontalPadding, vertical = 6.dp), ) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(1.dp), ) { Icon( imageVector = if (isSelected) item.iconFilled else item.iconOutlined, contentDescription = stringResource(item.titleRes), modifier = Modifier .size(22.dp) .graphicsLayer { scaleX = iconScale scaleY = iconScale translationY = with(density) { iconOffsetY.toPx() } }, tint = iconTint, ) Box( modifier = Modifier .height(if (isSelected) 16.dp else 0.dp) .graphicsLayer { alpha = labelAlpha scaleX = labelScale scaleY = labelScale }, contentAlignment = Alignment.Center, ) { Text( text = stringResource(item.titleRes), style = MaterialTheme.typography.labelSmall.copy( fontSize = 10.sp, fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, lineHeight = 12.sp, ), color = if (isSelected) { MaterialTheme.colorScheme.onSurface } else { MaterialTheme.colorScheme.onSurface.copy(alpha = .7f) }, maxLines = 1, ) } } if (hasBadge) { Box( Modifier .size(12.dp) .clip(CircleShape) .background(MaterialTheme.colorScheme.error) .align(Alignment.TopEnd), ) } } } @Preview @Composable fun BottomNavigationPreview() { GithubStoreTheme { CompositionLocalProvider( LocalBottomNavigationLiquid provides rememberLiquidState(), ) { BottomNavigation( currentScreen = GithubStoreGraph.HomeScreen, onNavigate = { }, isUpdateAvailable = true, ) } } } ================================================ FILE: composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/BottomNavigationUtils.kt ================================================ package zed.rainxch.githubstore.app.navigation import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Apps import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Person2 import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.outlined.Apps import androidx.compose.material.icons.outlined.Home import androidx.compose.material.icons.outlined.Person2 import androidx.compose.material.icons.outlined.Search import androidx.compose.ui.graphics.vector.ImageVector import org.jetbrains.compose.resources.StringResource import zed.rainxch.core.domain.getPlatform import zed.rainxch.core.domain.model.Platform import zed.rainxch.githubstore.core.presentation.res.* data class BottomNavigationItem( val titleRes: StringResource, val iconOutlined: ImageVector, val iconFilled: ImageVector, val screen: GithubStoreGraph, ) object BottomNavigationUtils { fun items(): List = listOf( BottomNavigationItem( titleRes = Res.string.bottom_nav_home_title, iconOutlined = Icons.Outlined.Home, iconFilled = Icons.Filled.Home, screen = GithubStoreGraph.HomeScreen, ), BottomNavigationItem( titleRes = Res.string.bottom_nav_search_title, iconOutlined = Icons.Outlined.Search, iconFilled = Icons.Filled.Search, screen = GithubStoreGraph.SearchScreen, ), BottomNavigationItem( titleRes = Res.string.bottom_nav_apps_title, iconOutlined = Icons.Outlined.Apps, iconFilled = Icons.Filled.Apps, screen = GithubStoreGraph.AppsScreen, ), BottomNavigationItem( titleRes = Res.string.bottom_nav_profile_title, iconOutlined = Icons.Outlined.Person2, iconFilled = Icons.Filled.Person2, screen = GithubStoreGraph.ProfileScreen, ), ) fun allowedScreens(): List = items() .filterNot { getPlatform() != Platform.ANDROID && it.screen == GithubStoreGraph.AppsScreen }.map { it.screen } } ================================================ FILE: composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/GithubStoreGraph.kt ================================================ package zed.rainxch.githubstore.app.navigation import kotlinx.serialization.Serializable @Serializable sealed interface GithubStoreGraph { @Serializable data object HomeScreen : GithubStoreGraph @Serializable data object SearchScreen : GithubStoreGraph @Serializable data object AuthenticationScreen : GithubStoreGraph @Serializable data class DetailsScreen( val repositoryId: Long = -1L, val owner: String = "", val repo: String = "", val isComingFromUpdate: Boolean = false, ) : GithubStoreGraph @Serializable data class DeveloperProfileScreen( val username: String, ) : GithubStoreGraph @Serializable data object ProfileScreen : GithubStoreGraph @Serializable data object FavouritesScreen : GithubStoreGraph @Serializable data object StarredReposScreen : GithubStoreGraph @Serializable data object AppsScreen : GithubStoreGraph @Serializable data object SponsorScreen : GithubStoreGraph } ================================================ FILE: composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/NavigationUtils.kt ================================================ package zed.rainxch.githubstore.app.navigation import androidx.navigation.NavBackStackEntry import androidx.navigation.toRoute fun NavBackStackEntry?.getCurrentScreen(): GithubStoreGraph? { if (this == null) return null val route = destination.route ?: return null return when { route.contains("HomeScreen") -> GithubStoreGraph.HomeScreen route.contains("SearchScreen") -> GithubStoreGraph.SearchScreen route.contains("AuthenticationScreen") -> GithubStoreGraph.AuthenticationScreen route.contains("DetailsScreen") -> toRoute() route.contains("DeveloperProfileScreen") -> toRoute() route.contains("ProfileScreen") -> GithubStoreGraph.ProfileScreen route.contains("FavouritesScreen") -> GithubStoreGraph.FavouritesScreen route.contains("StarredReposScreen") -> GithubStoreGraph.StarredReposScreen route.contains("AppsScreen") -> GithubStoreGraph.AppsScreen else -> null } } ================================================ FILE: composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt ================================================ package zed.rainxch.githubstore import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue 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.isMetaPressed import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.type import androidx.compose.ui.window.Window import androidx.compose.ui.window.application import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import zed.rainxch.githubstore.app.desktop.KeyboardNavigation import zed.rainxch.githubstore.app.desktop.KeyboardNavigationEvent import zed.rainxch.githubstore.app.di.initKoin import zed.rainxch.githubstore.core.presentation.res.Res import zed.rainxch.githubstore.core.presentation.res.app_icon import zed.rainxch.githubstore.core.presentation.res.app_name import java.awt.Desktop import kotlin.system.exitProcess fun main(args: Array) { initKoin() val deepLinkArg = args.firstOrNull() if (deepLinkArg != null && DesktopDeepLink.tryForwardToRunningInstance(deepLinkArg)) { exitProcess(0) } DesktopDeepLink.registerUriSchemeIfNeeded() application { var deepLinkUri by mutableStateOf(deepLinkArg) LaunchedEffect(Unit) { DesktopDeepLink.startInstanceListener { uri -> deepLinkUri = uri } } if (Desktop.isDesktopSupported()) { Desktop.getDesktop().let { desktop -> if (desktop.isSupported(Desktop.Action.APP_OPEN_URI)) { desktop.setOpenURIHandler { event -> deepLinkUri = event.uri.toString() } } } } Window( onCloseRequest = ::exitApplication, title = stringResource(Res.string.app_name), icon = painterResource(Res.drawable.app_icon), onKeyEvent = { keyEvent -> if (keyEvent.key == Key.F && keyEvent.type == KeyEventType.KeyDown) { if (keyEvent.isCtrlPressed || keyEvent.isMetaPressed) { KeyboardNavigation.onKeyClicked(KeyboardNavigationEvent.OnCtrlFClick) true } else { false } } else { false } }, ) { App(deepLinkUri = deepLinkUri) } } } ================================================ FILE: composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopDeepLink.kt ================================================ package zed.rainxch.githubstore import java.io.BufferedReader import java.io.File import java.io.InputStreamReader import java.io.PrintWriter import java.net.InetAddress import java.net.ServerSocket import java.net.Socket object DesktopDeepLink { private const val SINGLE_INSTANCE_PORT = 47632 private const val SCHEME = "githubstore" private const val DESKTOP_FILE_NAME = "github-store-deeplink" /** * On Windows and Linux, ensure the `githubstore://` protocol is registered. * - Windows: Writes to HKCU registry. * - Linux: Creates a `.desktop` file and registers via `xdg-mime`. * No-op on macOS (handled via Info.plist in the packaged .app). */ fun registerUriSchemeIfNeeded() { when { isWindows() -> registerWindows() isLinux() -> registerLinux() } } private fun registerWindows() { val checkResult = runCommand( "reg", "query", "HKCU\\SOFTWARE\\Classes\\$SCHEME", "/ve", ) if (checkResult != null && checkResult.contains("URL:")) return val exePath = resolveExePath() ?: return runCommand( "reg", "add", "HKCU\\SOFTWARE\\Classes\\$SCHEME", "/ve", "/d", "URL:GitHub Store Protocol", "/f", ) runCommand( "reg", "add", "HKCU\\SOFTWARE\\Classes\\$SCHEME", "/v", "URL Protocol", "/d", "", "/f", ) runCommand( "reg", "add", "HKCU\\SOFTWARE\\Classes\\$SCHEME\\DefaultIcon", "/ve", "/d", "\"$exePath\",1", "/f", ) runCommand( "reg", "add", "HKCU\\SOFTWARE\\Classes\\$SCHEME\\shell\\open\\command", "/ve", "/d", "\"$exePath\" \"%1\"", "/f", ) } private fun registerLinux() { val appsDir = File(System.getProperty("user.home"), ".local/share/applications") val desktopFile = File(appsDir, "$DESKTOP_FILE_NAME.desktop") if (desktopFile.exists()) return val exePath = resolveExePath() ?: return appsDir.mkdirs() desktopFile.writeText( """ [Desktop Entry] Type=Application Name=GitHub Store Exec="$exePath" %u Terminal=false MimeType=x-scheme-handler/$SCHEME; NoDisplay=true """.trimIndent(), ) runCommand("xdg-mime", "default", "$DESKTOP_FILE_NAME.desktop", "x-scheme-handler/$SCHEME") } /** * Try to forward a deep link URI to an already-running instance. * @return `true` if the URI was forwarded (this instance should exit), * `false` if no existing instance is running. */ fun tryForwardToRunningInstance(uri: String): Boolean = try { Socket("127.0.0.1", SINGLE_INSTANCE_PORT).use { socket -> PrintWriter(socket.getOutputStream(), true).println(uri) } true } catch (_: Exception) { false } /** * Start listening for URIs forwarded from new instances. * Calls [onUri] on the main thread when a URI is received. */ fun startInstanceListener(onUri: (String) -> Unit) { val thread = Thread({ try { val server = ServerSocket(SINGLE_INSTANCE_PORT, 50, InetAddress.getLoopbackAddress()) while (true) { val client = server.accept() try { val reader = BufferedReader(InputStreamReader(client.getInputStream())) val uri = reader.readLine() if (!uri.isNullOrBlank()) { onUri(uri.trim()) } } catch (_: Exception) { } finally { client.close() } } } catch (_: Exception) { } }, "DeepLinkListener") thread.isDaemon = true thread.start() } private fun isWindows(): Boolean = System.getProperty("os.name")?.lowercase()?.contains("win") == true private fun isLinux(): Boolean = System.getProperty("os.name")?.lowercase()?.contains("linux") == true private fun resolveExePath(): String? = try { ProcessHandle .current() .info() .command() .orElse(null) } catch (_: Exception) { null } private fun runCommand(vararg cmd: String): String? = try { val process = ProcessBuilder(*cmd) .redirectErrorStream(true) .start() val output = process.inputStream.bufferedReader().readText() process.waitFor() output } catch (_: Exception) { null } } ================================================ FILE: core/data/.gitignore ================================================ /build ================================================ FILE: core/data/build.gradle.kts ================================================ plugins { alias(libs.plugins.convention.kmp.library) alias(libs.plugins.convention.room) alias(libs.plugins.convention.buildkonfig) } android { buildFeatures { aidl = true } } kotlin { sourceSets { commonMain { dependencies { implementation(libs.kotlin.stdlib) implementation(projects.core.domain) implementation(libs.bundles.ktor.common) implementation(libs.bundles.koin.common) implementation(libs.touchlab.kermit) implementation(libs.datastore) implementation(libs.datastore.preferences) implementation(libs.kotlinx.datetime) } } androidMain { dependencies { implementation(libs.ktor.client.okhttp) implementation(libs.androidx.work.runtime) implementation(libs.shizuku.api) implementation(libs.shizuku.provider) compileOnly(libs.hidden.api.stub) } } jvmMain { dependencies { implementation(libs.ktor.client.okhttp) } } } } ================================================ FILE: core/data/schemas/zed.rainxch.core.data.local.db.AppDatabase/3.json ================================================ { "formatVersion": 1, "database": { "version": 3, "identityHash": "81a8b0bb930a5c839e0da7a5335e4991", "entities": [ { "tableName": "installed_apps", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `repoId` INTEGER NOT NULL, `repoName` TEXT NOT NULL, `repoOwner` TEXT NOT NULL, `repoOwnerAvatarUrl` TEXT NOT NULL, `repoDescription` TEXT, `primaryLanguage` TEXT, `repoUrl` TEXT NOT NULL, `installedVersion` TEXT NOT NULL, `installedAssetName` TEXT, `installedAssetUrl` TEXT, `latestVersion` TEXT, `latestAssetName` TEXT, `latestAssetUrl` TEXT, `latestAssetSize` INTEGER, `appName` TEXT NOT NULL, `installSource` TEXT NOT NULL, `installedAt` INTEGER NOT NULL, `lastCheckedAt` INTEGER NOT NULL, `lastUpdatedAt` INTEGER NOT NULL, `isUpdateAvailable` INTEGER NOT NULL, `updateCheckEnabled` INTEGER NOT NULL, `releaseNotes` TEXT, `systemArchitecture` TEXT NOT NULL, `fileExtension` TEXT NOT NULL, `isPendingInstall` INTEGER NOT NULL, `installedVersionName` TEXT, `installedVersionCode` INTEGER NOT NULL, `latestVersionName` TEXT, `latestVersionCode` INTEGER, PRIMARY KEY(`packageName`))", "fields": [ { "fieldPath": "packageName", "columnName": "packageName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "repoId", "columnName": "repoId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "repoName", "columnName": "repoName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "repoOwner", "columnName": "repoOwner", "affinity": "TEXT", "notNull": true }, { "fieldPath": "repoOwnerAvatarUrl", "columnName": "repoOwnerAvatarUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "repoDescription", "columnName": "repoDescription", "affinity": "TEXT" }, { "fieldPath": "primaryLanguage", "columnName": "primaryLanguage", "affinity": "TEXT" }, { "fieldPath": "repoUrl", "columnName": "repoUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "installedVersion", "columnName": "installedVersion", "affinity": "TEXT", "notNull": true }, { "fieldPath": "installedAssetName", "columnName": "installedAssetName", "affinity": "TEXT" }, { "fieldPath": "installedAssetUrl", "columnName": "installedAssetUrl", "affinity": "TEXT" }, { "fieldPath": "latestVersion", "columnName": "latestVersion", "affinity": "TEXT" }, { "fieldPath": "latestAssetName", "columnName": "latestAssetName", "affinity": "TEXT" }, { "fieldPath": "latestAssetUrl", "columnName": "latestAssetUrl", "affinity": "TEXT" }, { "fieldPath": "latestAssetSize", "columnName": "latestAssetSize", "affinity": "INTEGER" }, { "fieldPath": "appName", "columnName": "appName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "installSource", "columnName": "installSource", "affinity": "TEXT", "notNull": true }, { "fieldPath": "installedAt", "columnName": "installedAt", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckedAt", "columnName": "lastCheckedAt", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastUpdatedAt", "columnName": "lastUpdatedAt", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isUpdateAvailable", "columnName": "isUpdateAvailable", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "updateCheckEnabled", "columnName": "updateCheckEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "releaseNotes", "columnName": "releaseNotes", "affinity": "TEXT" }, { "fieldPath": "systemArchitecture", "columnName": "systemArchitecture", "affinity": "TEXT", "notNull": true }, { "fieldPath": "fileExtension", "columnName": "fileExtension", "affinity": "TEXT", "notNull": true }, { "fieldPath": "isPendingInstall", "columnName": "isPendingInstall", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "installedVersionName", "columnName": "installedVersionName", "affinity": "TEXT" }, { "fieldPath": "installedVersionCode", "columnName": "installedVersionCode", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "latestVersionName", "columnName": "latestVersionName", "affinity": "TEXT" }, { "fieldPath": "latestVersionCode", "columnName": "latestVersionCode", "affinity": "INTEGER" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "packageName" ] } }, { "tableName": "favorite_repos", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `repoName` TEXT NOT NULL, `repoOwner` TEXT NOT NULL, `repoOwnerAvatarUrl` TEXT NOT NULL, `repoDescription` TEXT, `primaryLanguage` TEXT, `repoUrl` TEXT NOT NULL, `isInstalled` INTEGER NOT NULL, `installedPackageName` TEXT, `latestVersion` TEXT, `latestReleaseUrl` TEXT, `addedAt` INTEGER NOT NULL, `lastSyncedAt` INTEGER NOT NULL, PRIMARY KEY(`repoId`))", "fields": [ { "fieldPath": "repoId", "columnName": "repoId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "repoName", "columnName": "repoName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "repoOwner", "columnName": "repoOwner", "affinity": "TEXT", "notNull": true }, { "fieldPath": "repoOwnerAvatarUrl", "columnName": "repoOwnerAvatarUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "repoDescription", "columnName": "repoDescription", "affinity": "TEXT" }, { "fieldPath": "primaryLanguage", "columnName": "primaryLanguage", "affinity": "TEXT" }, { "fieldPath": "repoUrl", "columnName": "repoUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "isInstalled", "columnName": "isInstalled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "installedPackageName", "columnName": "installedPackageName", "affinity": "TEXT" }, { "fieldPath": "latestVersion", "columnName": "latestVersion", "affinity": "TEXT" }, { "fieldPath": "latestReleaseUrl", "columnName": "latestReleaseUrl", "affinity": "TEXT" }, { "fieldPath": "addedAt", "columnName": "addedAt", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastSyncedAt", "columnName": "lastSyncedAt", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "repoId" ] } }, { "tableName": "update_history", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `packageName` TEXT NOT NULL, `appName` TEXT NOT NULL, `repoOwner` TEXT NOT NULL, `repoName` TEXT NOT NULL, `fromVersion` TEXT NOT NULL, `toVersion` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `updateSource` TEXT NOT NULL, `success` INTEGER NOT NULL, `errorMessage` TEXT)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "packageName", "columnName": "packageName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "appName", "columnName": "appName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "repoOwner", "columnName": "repoOwner", "affinity": "TEXT", "notNull": true }, { "fieldPath": "repoName", "columnName": "repoName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "fromVersion", "columnName": "fromVersion", "affinity": "TEXT", "notNull": true }, { "fieldPath": "toVersion", "columnName": "toVersion", "affinity": "TEXT", "notNull": true }, { "fieldPath": "updatedAt", "columnName": "updatedAt", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "updateSource", "columnName": "updateSource", "affinity": "TEXT", "notNull": true }, { "fieldPath": "success", "columnName": "success", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "errorMessage", "columnName": "errorMessage", "affinity": "TEXT" } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] } }, { "tableName": "starred_repos", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `repoName` TEXT NOT NULL, `repoOwner` TEXT NOT NULL, `repoOwnerAvatarUrl` TEXT NOT NULL, `repoDescription` TEXT, `primaryLanguage` TEXT, `repoUrl` TEXT NOT NULL, `stargazersCount` INTEGER NOT NULL, `forksCount` INTEGER NOT NULL, `openIssuesCount` INTEGER NOT NULL, `isInstalled` INTEGER NOT NULL, `installedPackageName` TEXT, `latestVersion` TEXT, `latestReleaseUrl` TEXT, `starredAt` INTEGER, `addedAt` INTEGER NOT NULL, `lastSyncedAt` INTEGER NOT NULL, PRIMARY KEY(`repoId`))", "fields": [ { "fieldPath": "repoId", "columnName": "repoId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "repoName", "columnName": "repoName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "repoOwner", "columnName": "repoOwner", "affinity": "TEXT", "notNull": true }, { "fieldPath": "repoOwnerAvatarUrl", "columnName": "repoOwnerAvatarUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "repoDescription", "columnName": "repoDescription", "affinity": "TEXT" }, { "fieldPath": "primaryLanguage", "columnName": "primaryLanguage", "affinity": "TEXT" }, { "fieldPath": "repoUrl", "columnName": "repoUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "stargazersCount", "columnName": "stargazersCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "forksCount", "columnName": "forksCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "openIssuesCount", "columnName": "openIssuesCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isInstalled", "columnName": "isInstalled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "installedPackageName", "columnName": "installedPackageName", "affinity": "TEXT" }, { "fieldPath": "latestVersion", "columnName": "latestVersion", "affinity": "TEXT" }, { "fieldPath": "latestReleaseUrl", "columnName": "latestReleaseUrl", "affinity": "TEXT" }, { "fieldPath": "starredAt", "columnName": "starredAt", "affinity": "INTEGER" }, { "fieldPath": "addedAt", "columnName": "addedAt", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastSyncedAt", "columnName": "lastSyncedAt", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "repoId" ] } } ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '81a8b0bb930a5c839e0da7a5335e4991')" ] } } ================================================ FILE: core/data/schemas/zed.rainxch.core.data.local.db.AppDatabase/4.json ================================================ { "formatVersion": 1, "database": { "version": 4, "identityHash": "7d8cd14625a6c8b570092980957d57ce", "entities": [ { "tableName": "installed_apps", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `repoId` INTEGER NOT NULL, `repoName` TEXT NOT NULL, `repoOwner` TEXT NOT NULL, `repoOwnerAvatarUrl` TEXT NOT NULL, `repoDescription` TEXT, `primaryLanguage` TEXT, `repoUrl` TEXT NOT NULL, `installedVersion` TEXT NOT NULL, `installedAssetName` TEXT, `installedAssetUrl` TEXT, `latestVersion` TEXT, `latestAssetName` TEXT, `latestAssetUrl` TEXT, `latestAssetSize` INTEGER, `appName` TEXT NOT NULL, `installSource` TEXT NOT NULL, `installedAt` INTEGER NOT NULL, `lastCheckedAt` INTEGER NOT NULL, `lastUpdatedAt` INTEGER NOT NULL, `isUpdateAvailable` INTEGER NOT NULL, `updateCheckEnabled` INTEGER NOT NULL, `releaseNotes` TEXT, `systemArchitecture` TEXT NOT NULL, `fileExtension` TEXT NOT NULL, `isPendingInstall` INTEGER NOT NULL, `installedVersionName` TEXT, `installedVersionCode` INTEGER NOT NULL, `latestVersionName` TEXT, `latestVersionCode` INTEGER, PRIMARY KEY(`packageName`))", "fields": [ { "fieldPath": "packageName", "columnName": "packageName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "repoId", "columnName": "repoId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "repoName", "columnName": "repoName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "repoOwner", "columnName": "repoOwner", "affinity": "TEXT", "notNull": true }, { "fieldPath": "repoOwnerAvatarUrl", "columnName": "repoOwnerAvatarUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "repoDescription", "columnName": "repoDescription", "affinity": "TEXT" }, { "fieldPath": "primaryLanguage", "columnName": "primaryLanguage", "affinity": "TEXT" }, { "fieldPath": "repoUrl", "columnName": "repoUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "installedVersion", "columnName": "installedVersion", "affinity": "TEXT", "notNull": true }, { "fieldPath": "installedAssetName", "columnName": "installedAssetName", "affinity": "TEXT" }, { "fieldPath": "installedAssetUrl", "columnName": "installedAssetUrl", "affinity": "TEXT" }, { "fieldPath": "latestVersion", "columnName": "latestVersion", "affinity": "TEXT" }, { "fieldPath": "latestAssetName", "columnName": "latestAssetName", "affinity": "TEXT" }, { "fieldPath": "latestAssetUrl", "columnName": "latestAssetUrl", "affinity": "TEXT" }, { "fieldPath": "latestAssetSize", "columnName": "latestAssetSize", "affinity": "INTEGER" }, { "fieldPath": "appName", "columnName": "appName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "installSource", "columnName": "installSource", "affinity": "TEXT", "notNull": true }, { "fieldPath": "installedAt", "columnName": "installedAt", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckedAt", "columnName": "lastCheckedAt", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastUpdatedAt", "columnName": "lastUpdatedAt", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isUpdateAvailable", "columnName": "isUpdateAvailable", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "updateCheckEnabled", "columnName": "updateCheckEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "releaseNotes", "columnName": "releaseNotes", "affinity": "TEXT" }, { "fieldPath": "systemArchitecture", "columnName": "systemArchitecture", "affinity": "TEXT", "notNull": true }, { "fieldPath": "fileExtension", "columnName": "fileExtension", "affinity": "TEXT", "notNull": true }, { "fieldPath": "isPendingInstall", "columnName": "isPendingInstall", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "installedVersionName", "columnName": "installedVersionName", "affinity": "TEXT" }, { "fieldPath": "installedVersionCode", "columnName": "installedVersionCode", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "latestVersionName", "columnName": "latestVersionName", "affinity": "TEXT" }, { "fieldPath": "latestVersionCode", "columnName": "latestVersionCode", "affinity": "INTEGER" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "packageName" ] } }, { "tableName": "favorite_repos", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `repoName` TEXT NOT NULL, `repoOwner` TEXT NOT NULL, `repoOwnerAvatarUrl` TEXT NOT NULL, `repoDescription` TEXT, `primaryLanguage` TEXT, `repoUrl` TEXT NOT NULL, `isInstalled` INTEGER NOT NULL, `installedPackageName` TEXT, `latestVersion` TEXT, `latestReleaseUrl` TEXT, `addedAt` INTEGER NOT NULL, `lastSyncedAt` INTEGER NOT NULL, PRIMARY KEY(`repoId`))", "fields": [ { "fieldPath": "repoId", "columnName": "repoId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "repoName", "columnName": "repoName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "repoOwner", "columnName": "repoOwner", "affinity": "TEXT", "notNull": true }, { "fieldPath": "repoOwnerAvatarUrl", "columnName": "repoOwnerAvatarUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "repoDescription", "columnName": "repoDescription", "affinity": "TEXT" }, { "fieldPath": "primaryLanguage", "columnName": "primaryLanguage", "affinity": "TEXT" }, { "fieldPath": "repoUrl", "columnName": "repoUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "isInstalled", "columnName": "isInstalled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "installedPackageName", "columnName": "installedPackageName", "affinity": "TEXT" }, { "fieldPath": "latestVersion", "columnName": "latestVersion", "affinity": "TEXT" }, { "fieldPath": "latestReleaseUrl", "columnName": "latestReleaseUrl", "affinity": "TEXT" }, { "fieldPath": "addedAt", "columnName": "addedAt", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastSyncedAt", "columnName": "lastSyncedAt", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "repoId" ] } }, { "tableName": "update_history", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `packageName` TEXT NOT NULL, `appName` TEXT NOT NULL, `repoOwner` TEXT NOT NULL, `repoName` TEXT NOT NULL, `fromVersion` TEXT NOT NULL, `toVersion` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `updateSource` TEXT NOT NULL, `success` INTEGER NOT NULL, `errorMessage` TEXT)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "packageName", "columnName": "packageName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "appName", "columnName": "appName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "repoOwner", "columnName": "repoOwner", "affinity": "TEXT", "notNull": true }, { "fieldPath": "repoName", "columnName": "repoName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "fromVersion", "columnName": "fromVersion", "affinity": "TEXT", "notNull": true }, { "fieldPath": "toVersion", "columnName": "toVersion", "affinity": "TEXT", "notNull": true }, { "fieldPath": "updatedAt", "columnName": "updatedAt", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "updateSource", "columnName": "updateSource", "affinity": "TEXT", "notNull": true }, { "fieldPath": "success", "columnName": "success", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "errorMessage", "columnName": "errorMessage", "affinity": "TEXT" } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] } }, { "tableName": "starred_repos", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `repoName` TEXT NOT NULL, `repoOwner` TEXT NOT NULL, `repoOwnerAvatarUrl` TEXT NOT NULL, `repoDescription` TEXT, `primaryLanguage` TEXT, `repoUrl` TEXT NOT NULL, `stargazersCount` INTEGER NOT NULL, `forksCount` INTEGER NOT NULL, `openIssuesCount` INTEGER NOT NULL, `isInstalled` INTEGER NOT NULL, `installedPackageName` TEXT, `latestVersion` TEXT, `latestReleaseUrl` TEXT, `starredAt` INTEGER, `addedAt` INTEGER NOT NULL, `lastSyncedAt` INTEGER NOT NULL, PRIMARY KEY(`repoId`))", "fields": [ { "fieldPath": "repoId", "columnName": "repoId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "repoName", "columnName": "repoName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "repoOwner", "columnName": "repoOwner", "affinity": "TEXT", "notNull": true }, { "fieldPath": "repoOwnerAvatarUrl", "columnName": "repoOwnerAvatarUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "repoDescription", "columnName": "repoDescription", "affinity": "TEXT" }, { "fieldPath": "primaryLanguage", "columnName": "primaryLanguage", "affinity": "TEXT" }, { "fieldPath": "repoUrl", "columnName": "repoUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "stargazersCount", "columnName": "stargazersCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "forksCount", "columnName": "forksCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "openIssuesCount", "columnName": "openIssuesCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isInstalled", "columnName": "isInstalled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "installedPackageName", "columnName": "installedPackageName", "affinity": "TEXT" }, { "fieldPath": "latestVersion", "columnName": "latestVersion", "affinity": "TEXT" }, { "fieldPath": "latestReleaseUrl", "columnName": "latestReleaseUrl", "affinity": "TEXT" }, { "fieldPath": "starredAt", "columnName": "starredAt", "affinity": "INTEGER" }, { "fieldPath": "addedAt", "columnName": "addedAt", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastSyncedAt", "columnName": "lastSyncedAt", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "repoId" ] } }, { "tableName": "cache_entries", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `jsonData` TEXT NOT NULL, `cachedAt` INTEGER NOT NULL, `expiresAt` INTEGER NOT NULL, PRIMARY KEY(`key`))", "fields": [ { "fieldPath": "key", "columnName": "key", "affinity": "TEXT", "notNull": true }, { "fieldPath": "jsonData", "columnName": "jsonData", "affinity": "TEXT", "notNull": true }, { "fieldPath": "cachedAt", "columnName": "cachedAt", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "expiresAt", "columnName": "expiresAt", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "key" ] } } ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7d8cd14625a6c8b570092980957d57ce')" ] } } ================================================ FILE: core/data/schemas/zed.rainxch.core.data.local.db.AppDatabase/5.json ================================================ { "formatVersion": 1, "database": { "version": 5, "identityHash": "9d5111af6d583511c868ab59557e4ea8", "entities": [ { "tableName": "installed_apps", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `repoId` INTEGER NOT NULL, `repoName` TEXT NOT NULL, `repoOwner` TEXT NOT NULL, `repoOwnerAvatarUrl` TEXT NOT NULL, `repoDescription` TEXT, `primaryLanguage` TEXT, `repoUrl` TEXT NOT NULL, `installedVersion` TEXT NOT NULL, `installedAssetName` TEXT, `installedAssetUrl` TEXT, `latestVersion` TEXT, `latestAssetName` TEXT, `latestAssetUrl` TEXT, `latestAssetSize` INTEGER, `appName` TEXT NOT NULL, `installSource` TEXT NOT NULL, `signingFingerprint` TEXT, `installedAt` INTEGER NOT NULL, `lastCheckedAt` INTEGER NOT NULL, `lastUpdatedAt` INTEGER NOT NULL, `isUpdateAvailable` INTEGER NOT NULL, `updateCheckEnabled` INTEGER NOT NULL, `releaseNotes` TEXT, `systemArchitecture` TEXT NOT NULL, `fileExtension` TEXT NOT NULL, `isPendingInstall` INTEGER NOT NULL, `installedVersionName` TEXT, `installedVersionCode` INTEGER NOT NULL, `latestVersionName` TEXT, `latestVersionCode` INTEGER, PRIMARY KEY(`packageName`))", "fields": [ { "fieldPath": "packageName", "columnName": "packageName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "repoId", "columnName": "repoId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "repoName", "columnName": "repoName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "repoOwner", "columnName": "repoOwner", "affinity": "TEXT", "notNull": true }, { "fieldPath": "repoOwnerAvatarUrl", "columnName": "repoOwnerAvatarUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "repoDescription", "columnName": "repoDescription", "affinity": "TEXT" }, { "fieldPath": "primaryLanguage", "columnName": "primaryLanguage", "affinity": "TEXT" }, { "fieldPath": "repoUrl", "columnName": "repoUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "installedVersion", "columnName": "installedVersion", "affinity": "TEXT", "notNull": true }, { "fieldPath": "installedAssetName", "columnName": "installedAssetName", "affinity": "TEXT" }, { "fieldPath": "installedAssetUrl", "columnName": "installedAssetUrl", "affinity": "TEXT" }, { "fieldPath": "latestVersion", "columnName": "latestVersion", "affinity": "TEXT" }, { "fieldPath": "latestAssetName", "columnName": "latestAssetName", "affinity": "TEXT" }, { "fieldPath": "latestAssetUrl", "columnName": "latestAssetUrl", "affinity": "TEXT" }, { "fieldPath": "latestAssetSize", "columnName": "latestAssetSize", "affinity": "INTEGER" }, { "fieldPath": "appName", "columnName": "appName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "installSource", "columnName": "installSource", "affinity": "TEXT", "notNull": true }, { "fieldPath": "signingFingerprint", "columnName": "signingFingerprint", "affinity": "TEXT" }, { "fieldPath": "installedAt", "columnName": "installedAt", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckedAt", "columnName": "lastCheckedAt", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastUpdatedAt", "columnName": "lastUpdatedAt", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isUpdateAvailable", "columnName": "isUpdateAvailable", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "updateCheckEnabled", "columnName": "updateCheckEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "releaseNotes", "columnName": "releaseNotes", "affinity": "TEXT" }, { "fieldPath": "systemArchitecture", "columnName": "systemArchitecture", "affinity": "TEXT", "notNull": true }, { "fieldPath": "fileExtension", "columnName": "fileExtension", "affinity": "TEXT", "notNull": true }, { "fieldPath": "isPendingInstall", "columnName": "isPendingInstall", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "installedVersionName", "columnName": "installedVersionName", "affinity": "TEXT" }, { "fieldPath": "installedVersionCode", "columnName": "installedVersionCode", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "latestVersionName", "columnName": "latestVersionName", "affinity": "TEXT" }, { "fieldPath": "latestVersionCode", "columnName": "latestVersionCode", "affinity": "INTEGER" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "packageName" ] } }, { "tableName": "favorite_repos", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `repoName` TEXT NOT NULL, `repoOwner` TEXT NOT NULL, `repoOwnerAvatarUrl` TEXT NOT NULL, `repoDescription` TEXT, `primaryLanguage` TEXT, `repoUrl` TEXT NOT NULL, `isInstalled` INTEGER NOT NULL, `installedPackageName` TEXT, `latestVersion` TEXT, `latestReleaseUrl` TEXT, `addedAt` INTEGER NOT NULL, `lastSyncedAt` INTEGER NOT NULL, PRIMARY KEY(`repoId`))", "fields": [ { "fieldPath": "repoId", "columnName": "repoId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "repoName", "columnName": "repoName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "repoOwner", "columnName": "repoOwner", "affinity": "TEXT", "notNull": true }, { "fieldPath": "repoOwnerAvatarUrl", "columnName": "repoOwnerAvatarUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "repoDescription", "columnName": "repoDescription", "affinity": "TEXT" }, { "fieldPath": "primaryLanguage", "columnName": "primaryLanguage", "affinity": "TEXT" }, { "fieldPath": "repoUrl", "columnName": "repoUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "isInstalled", "columnName": "isInstalled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "installedPackageName", "columnName": "installedPackageName", "affinity": "TEXT" }, { "fieldPath": "latestVersion", "columnName": "latestVersion", "affinity": "TEXT" }, { "fieldPath": "latestReleaseUrl", "columnName": "latestReleaseUrl", "affinity": "TEXT" }, { "fieldPath": "addedAt", "columnName": "addedAt", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastSyncedAt", "columnName": "lastSyncedAt", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "repoId" ] } }, { "tableName": "update_history", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `packageName` TEXT NOT NULL, `appName` TEXT NOT NULL, `repoOwner` TEXT NOT NULL, `repoName` TEXT NOT NULL, `fromVersion` TEXT NOT NULL, `toVersion` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `updateSource` TEXT NOT NULL, `success` INTEGER NOT NULL, `errorMessage` TEXT)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "packageName", "columnName": "packageName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "appName", "columnName": "appName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "repoOwner", "columnName": "repoOwner", "affinity": "TEXT", "notNull": true }, { "fieldPath": "repoName", "columnName": "repoName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "fromVersion", "columnName": "fromVersion", "affinity": "TEXT", "notNull": true }, { "fieldPath": "toVersion", "columnName": "toVersion", "affinity": "TEXT", "notNull": true }, { "fieldPath": "updatedAt", "columnName": "updatedAt", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "updateSource", "columnName": "updateSource", "affinity": "TEXT", "notNull": true }, { "fieldPath": "success", "columnName": "success", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "errorMessage", "columnName": "errorMessage", "affinity": "TEXT" } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] } }, { "tableName": "starred_repos", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `repoName` TEXT NOT NULL, `repoOwner` TEXT NOT NULL, `repoOwnerAvatarUrl` TEXT NOT NULL, `repoDescription` TEXT, `primaryLanguage` TEXT, `repoUrl` TEXT NOT NULL, `stargazersCount` INTEGER NOT NULL, `forksCount` INTEGER NOT NULL, `openIssuesCount` INTEGER NOT NULL, `isInstalled` INTEGER NOT NULL, `installedPackageName` TEXT, `latestVersion` TEXT, `latestReleaseUrl` TEXT, `starredAt` INTEGER, `addedAt` INTEGER NOT NULL, `lastSyncedAt` INTEGER NOT NULL, PRIMARY KEY(`repoId`))", "fields": [ { "fieldPath": "repoId", "columnName": "repoId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "repoName", "columnName": "repoName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "repoOwner", "columnName": "repoOwner", "affinity": "TEXT", "notNull": true }, { "fieldPath": "repoOwnerAvatarUrl", "columnName": "repoOwnerAvatarUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "repoDescription", "columnName": "repoDescription", "affinity": "TEXT" }, { "fieldPath": "primaryLanguage", "columnName": "primaryLanguage", "affinity": "TEXT" }, { "fieldPath": "repoUrl", "columnName": "repoUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "stargazersCount", "columnName": "stargazersCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "forksCount", "columnName": "forksCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "openIssuesCount", "columnName": "openIssuesCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isInstalled", "columnName": "isInstalled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "installedPackageName", "columnName": "installedPackageName", "affinity": "TEXT" }, { "fieldPath": "latestVersion", "columnName": "latestVersion", "affinity": "TEXT" }, { "fieldPath": "latestReleaseUrl", "columnName": "latestReleaseUrl", "affinity": "TEXT" }, { "fieldPath": "starredAt", "columnName": "starredAt", "affinity": "INTEGER" }, { "fieldPath": "addedAt", "columnName": "addedAt", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastSyncedAt", "columnName": "lastSyncedAt", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "repoId" ] } }, { "tableName": "cache_entries", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `jsonData` TEXT NOT NULL, `cachedAt` INTEGER NOT NULL, `expiresAt` INTEGER NOT NULL, PRIMARY KEY(`key`))", "fields": [ { "fieldPath": "key", "columnName": "key", "affinity": "TEXT", "notNull": true }, { "fieldPath": "jsonData", "columnName": "jsonData", "affinity": "TEXT", "notNull": true }, { "fieldPath": "cachedAt", "columnName": "cachedAt", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "expiresAt", "columnName": "expiresAt", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "key" ] } } ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9d5111af6d583511c868ab59557e4ea8')" ] } } ================================================ FILE: core/data/schemas/zed.rainxch.core.data.local.db.AppDatabase/6.json ================================================ { "formatVersion": 1, "database": { "version": 6, "identityHash": "ce64b232de8d6ab9d95e3adabf7c7c43", "entities": [ { "tableName": "installed_apps", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `repoId` INTEGER NOT NULL, `repoName` TEXT NOT NULL, `repoOwner` TEXT NOT NULL, `repoOwnerAvatarUrl` TEXT NOT NULL, `repoDescription` TEXT, `primaryLanguage` TEXT, `repoUrl` TEXT NOT NULL, `installedVersion` TEXT NOT NULL, `installedAssetName` TEXT, `installedAssetUrl` TEXT, `latestVersion` TEXT, `latestAssetName` TEXT, `latestAssetUrl` TEXT, `latestAssetSize` INTEGER, `appName` TEXT NOT NULL, `installSource` TEXT NOT NULL, `signingFingerprint` TEXT, `installedAt` INTEGER NOT NULL, `lastCheckedAt` INTEGER NOT NULL, `lastUpdatedAt` INTEGER NOT NULL, `isUpdateAvailable` INTEGER NOT NULL, `updateCheckEnabled` INTEGER NOT NULL, `releaseNotes` TEXT, `systemArchitecture` TEXT NOT NULL, `fileExtension` TEXT NOT NULL, `isPendingInstall` INTEGER NOT NULL, `installedVersionName` TEXT, `installedVersionCode` INTEGER NOT NULL, `latestVersionName` TEXT, `latestVersionCode` INTEGER, PRIMARY KEY(`packageName`))", "fields": [ { "fieldPath": "packageName", "columnName": "packageName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "repoId", "columnName": "repoId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "repoName", "columnName": "repoName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "repoOwner", "columnName": "repoOwner", "affinity": "TEXT", "notNull": true }, { "fieldPath": "repoOwnerAvatarUrl", "columnName": "repoOwnerAvatarUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "repoDescription", "columnName": "repoDescription", "affinity": "TEXT" }, { "fieldPath": "primaryLanguage", "columnName": "primaryLanguage", "affinity": "TEXT" }, { "fieldPath": "repoUrl", "columnName": "repoUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "installedVersion", "columnName": "installedVersion", "affinity": "TEXT", "notNull": true }, { "fieldPath": "installedAssetName", "columnName": "installedAssetName", "affinity": "TEXT" }, { "fieldPath": "installedAssetUrl", "columnName": "installedAssetUrl", "affinity": "TEXT" }, { "fieldPath": "latestVersion", "columnName": "latestVersion", "affinity": "TEXT" }, { "fieldPath": "latestAssetName", "columnName": "latestAssetName", "affinity": "TEXT" }, { "fieldPath": "latestAssetUrl", "columnName": "latestAssetUrl", "affinity": "TEXT" }, { "fieldPath": "latestAssetSize", "columnName": "latestAssetSize", "affinity": "INTEGER" }, { "fieldPath": "appName", "columnName": "appName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "installSource", "columnName": "installSource", "affinity": "TEXT", "notNull": true }, { "fieldPath": "signingFingerprint", "columnName": "signingFingerprint", "affinity": "TEXT" }, { "fieldPath": "installedAt", "columnName": "installedAt", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastCheckedAt", "columnName": "lastCheckedAt", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastUpdatedAt", "columnName": "lastUpdatedAt", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isUpdateAvailable", "columnName": "isUpdateAvailable", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "updateCheckEnabled", "columnName": "updateCheckEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "releaseNotes", "columnName": "releaseNotes", "affinity": "TEXT" }, { "fieldPath": "systemArchitecture", "columnName": "systemArchitecture", "affinity": "TEXT", "notNull": true }, { "fieldPath": "fileExtension", "columnName": "fileExtension", "affinity": "TEXT", "notNull": true }, { "fieldPath": "isPendingInstall", "columnName": "isPendingInstall", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "installedVersionName", "columnName": "installedVersionName", "affinity": "TEXT" }, { "fieldPath": "installedVersionCode", "columnName": "installedVersionCode", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "latestVersionName", "columnName": "latestVersionName", "affinity": "TEXT" }, { "fieldPath": "latestVersionCode", "columnName": "latestVersionCode", "affinity": "INTEGER" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "packageName" ] } }, { "tableName": "favorite_repos", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `repoName` TEXT NOT NULL, `repoOwner` TEXT NOT NULL, `repoOwnerAvatarUrl` TEXT NOT NULL, `repoDescription` TEXT, `primaryLanguage` TEXT, `repoUrl` TEXT NOT NULL, `isInstalled` INTEGER NOT NULL, `installedPackageName` TEXT, `latestVersion` TEXT, `latestReleaseUrl` TEXT, `addedAt` INTEGER NOT NULL, `lastSyncedAt` INTEGER NOT NULL, PRIMARY KEY(`repoId`))", "fields": [ { "fieldPath": "repoId", "columnName": "repoId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "repoName", "columnName": "repoName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "repoOwner", "columnName": "repoOwner", "affinity": "TEXT", "notNull": true }, { "fieldPath": "repoOwnerAvatarUrl", "columnName": "repoOwnerAvatarUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "repoDescription", "columnName": "repoDescription", "affinity": "TEXT" }, { "fieldPath": "primaryLanguage", "columnName": "primaryLanguage", "affinity": "TEXT" }, { "fieldPath": "repoUrl", "columnName": "repoUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "isInstalled", "columnName": "isInstalled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "installedPackageName", "columnName": "installedPackageName", "affinity": "TEXT" }, { "fieldPath": "latestVersion", "columnName": "latestVersion", "affinity": "TEXT" }, { "fieldPath": "latestReleaseUrl", "columnName": "latestReleaseUrl", "affinity": "TEXT" }, { "fieldPath": "addedAt", "columnName": "addedAt", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastSyncedAt", "columnName": "lastSyncedAt", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "repoId" ] } }, { "tableName": "update_history", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `packageName` TEXT NOT NULL, `appName` TEXT NOT NULL, `repoOwner` TEXT NOT NULL, `repoName` TEXT NOT NULL, `fromVersion` TEXT NOT NULL, `toVersion` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `updateSource` TEXT NOT NULL, `success` INTEGER NOT NULL, `errorMessage` TEXT)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "packageName", "columnName": "packageName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "appName", "columnName": "appName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "repoOwner", "columnName": "repoOwner", "affinity": "TEXT", "notNull": true }, { "fieldPath": "repoName", "columnName": "repoName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "fromVersion", "columnName": "fromVersion", "affinity": "TEXT", "notNull": true }, { "fieldPath": "toVersion", "columnName": "toVersion", "affinity": "TEXT", "notNull": true }, { "fieldPath": "updatedAt", "columnName": "updatedAt", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "updateSource", "columnName": "updateSource", "affinity": "TEXT", "notNull": true }, { "fieldPath": "success", "columnName": "success", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "errorMessage", "columnName": "errorMessage", "affinity": "TEXT" } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] } }, { "tableName": "starred_repos", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `repoName` TEXT NOT NULL, `repoOwner` TEXT NOT NULL, `repoOwnerAvatarUrl` TEXT NOT NULL, `repoDescription` TEXT, `primaryLanguage` TEXT, `repoUrl` TEXT NOT NULL, `stargazersCount` INTEGER NOT NULL, `forksCount` INTEGER NOT NULL, `openIssuesCount` INTEGER NOT NULL, `isInstalled` INTEGER NOT NULL, `installedPackageName` TEXT, `latestVersion` TEXT, `latestReleaseUrl` TEXT, `starredAt` INTEGER, `addedAt` INTEGER NOT NULL, `lastSyncedAt` INTEGER NOT NULL, PRIMARY KEY(`repoId`))", "fields": [ { "fieldPath": "repoId", "columnName": "repoId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "repoName", "columnName": "repoName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "repoOwner", "columnName": "repoOwner", "affinity": "TEXT", "notNull": true }, { "fieldPath": "repoOwnerAvatarUrl", "columnName": "repoOwnerAvatarUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "repoDescription", "columnName": "repoDescription", "affinity": "TEXT" }, { "fieldPath": "primaryLanguage", "columnName": "primaryLanguage", "affinity": "TEXT" }, { "fieldPath": "repoUrl", "columnName": "repoUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "stargazersCount", "columnName": "stargazersCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "forksCount", "columnName": "forksCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "openIssuesCount", "columnName": "openIssuesCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isInstalled", "columnName": "isInstalled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "installedPackageName", "columnName": "installedPackageName", "affinity": "TEXT" }, { "fieldPath": "latestVersion", "columnName": "latestVersion", "affinity": "TEXT" }, { "fieldPath": "latestReleaseUrl", "columnName": "latestReleaseUrl", "affinity": "TEXT" }, { "fieldPath": "starredAt", "columnName": "starredAt", "affinity": "INTEGER" }, { "fieldPath": "addedAt", "columnName": "addedAt", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastSyncedAt", "columnName": "lastSyncedAt", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "repoId" ] } }, { "tableName": "cache_entries", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `jsonData` TEXT NOT NULL, `cachedAt` INTEGER NOT NULL, `expiresAt` INTEGER NOT NULL, PRIMARY KEY(`key`))", "fields": [ { "fieldPath": "key", "columnName": "key", "affinity": "TEXT", "notNull": true }, { "fieldPath": "jsonData", "columnName": "jsonData", "affinity": "TEXT", "notNull": true }, { "fieldPath": "cachedAt", "columnName": "cachedAt", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "expiresAt", "columnName": "expiresAt", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "key" ] } }, { "tableName": "seen_repos", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `seenAt` INTEGER NOT NULL, PRIMARY KEY(`repoId`))", "fields": [ { "fieldPath": "repoId", "columnName": "repoId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "seenAt", "columnName": "seenAt", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "repoId" ] } } ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ce64b232de8d6ab9d95e3adabf7c7c43')" ] } } ================================================ FILE: core/data/src/androidMain/AndroidManifest.xml ================================================ ================================================ FILE: core/data/src/androidMain/aidl/zed/rainxch/core/data/services/shizuku/IShizukuInstallerService.aidl ================================================ package zed.rainxch.core.data.services.shizuku; interface IShizukuInstallerService { int installPackage(in ParcelFileDescriptor pfd, long fileSize); int uninstallPackage(String packageName); void destroy(); } ================================================ FILE: core/data/src/androidMain/kotlin/zed/rainxch/core/data/di/PlatformModule.android.kt ================================================ package zed.rainxch.core.data.di import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import kotlinx.coroutines.CoroutineScope import org.koin.android.ext.koin.androidContext import org.koin.dsl.module import zed.rainxch.core.data.local.data_store.createDataStore import zed.rainxch.core.data.local.db.AppDatabase import zed.rainxch.core.data.local.db.initDatabase import zed.rainxch.core.data.services.AndroidDownloader import zed.rainxch.core.data.services.AndroidFileLocationsProvider import zed.rainxch.core.data.services.AndroidInstaller import zed.rainxch.core.data.services.AndroidInstallerInfoExtractor import zed.rainxch.core.data.services.AndroidLocalizationManager import zed.rainxch.core.data.services.AndroidPackageMonitor import zed.rainxch.core.data.services.AndroidUpdateScheduleManager import zed.rainxch.core.data.services.FileLocationsProvider import zed.rainxch.core.data.services.LocalizationManager import zed.rainxch.core.data.services.shizuku.AndroidInstallerStatusProvider import zed.rainxch.core.data.services.shizuku.ShizukuInstallerWrapper import zed.rainxch.core.data.services.shizuku.ShizukuServiceManager import zed.rainxch.core.data.utils.AndroidAppLauncher import zed.rainxch.core.data.utils.AndroidBrowserHelper import zed.rainxch.core.data.utils.AndroidClipboardHelper import zed.rainxch.core.data.utils.AndroidShareManager import zed.rainxch.core.domain.network.Downloader import zed.rainxch.core.domain.system.Installer import zed.rainxch.core.domain.system.InstallerStatusProvider import zed.rainxch.core.domain.system.PackageMonitor import zed.rainxch.core.domain.system.UpdateScheduleManager import zed.rainxch.core.domain.utils.AppLauncher import zed.rainxch.core.domain.utils.BrowserHelper import zed.rainxch.core.domain.utils.ClipboardHelper import zed.rainxch.core.domain.utils.ShareManager actual val corePlatformModule = module { // Core single { AndroidDownloader( files = get(), ) } // AndroidInstaller — registered by class so the wrapper can inject it single { AndroidInstaller( context = get(), installerInfoExtractor = AndroidInstallerInfoExtractor(androidContext()), ) } // ShizukuServiceManager — manages Shizuku lifecycle, permissions, service binding single { ShizukuServiceManager( context = androidContext(), ).also { it.initialize() } } // Installer — the ShizukuInstallerWrapper is the public Installer singleton. // It delegates to AndroidInstaller by default, intercepting with Shizuku when enabled. single { ShizukuInstallerWrapper( androidInstaller = get(), shizukuServiceManager = get(), tweaksRepository = get(), ).also { wrapper -> wrapper.observeInstallerPreference(get()) } } // InstallerStatusProvider — exposes Shizuku availability to the UI layer single { AndroidInstallerStatusProvider( shizukuServiceManager = get(), scope = get(), ) } single { AndroidFileLocationsProvider(context = get()) } single { AndroidPackageMonitor(androidContext()) } single { AndroidLocalizationManager() } // Locals single { initDatabase(androidContext()) } single> { createDataStore(androidContext()) } // Utils single { AndroidBrowserHelper(androidContext()) } single { AndroidClipboardHelper(androidContext()) } single { AndroidAppLauncher( context = androidContext(), logger = get(), ) } single { AndroidShareManager( context = androidContext(), ) } single { AndroidUpdateScheduleManager( context = androidContext(), ) } } ================================================ FILE: core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/data_store/createDataStore.kt ================================================ package zed.rainxch.core.data.local.data_store import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences fun createDataStore(context: Context): DataStore = createDataStore( producePath = { context.filesDir.resolve(_root_ide_package_.zed.rainxch.core.data.local.data_store.dataStoreFileName).absolutePath }, ) ================================================ FILE: core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/initDatabase.kt ================================================ package zed.rainxch.core.data.local.db import android.content.Context import androidx.room.Room import kotlinx.coroutines.Dispatchers import zed.rainxch.core.data.local.db.migrations.MIGRATION_1_2 import zed.rainxch.core.data.local.db.migrations.MIGRATION_2_3 import zed.rainxch.core.data.local.db.migrations.MIGRATION_3_4 import zed.rainxch.core.data.local.db.migrations.MIGRATION_4_5 import zed.rainxch.core.data.local.db.migrations.MIGRATION_5_6 fun initDatabase(context: Context): AppDatabase { val appContext = context.applicationContext val dbFile = appContext.getDatabasePath("github_store.db") return Room .databaseBuilder( context = appContext, name = dbFile.absolutePath, ).setQueryCoroutineContext(Dispatchers.IO) .addMigrations( MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, ).build() } ================================================ FILE: core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/MIGRATION_1_2.kt ================================================ package zed.rainxch.core.data.local.db.migrations import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE installed_apps ADD COLUMN installedVersionName TEXT") db.execSQL("ALTER TABLE installed_apps ADD COLUMN installedVersionCode INTEGER NOT NULL DEFAULT 0") db.execSQL("ALTER TABLE installed_apps ADD COLUMN latestVersionName TEXT") db.execSQL("ALTER TABLE installed_apps ADD COLUMN latestVersionCode INTEGER") } } ================================================ FILE: core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/MIGRATION_2_3.kt ================================================ package zed.rainxch.core.data.local.db.migrations import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase val MIGRATION_2_3 = object : Migration(2, 3) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL( """ CREATE TABLE starred_repos ( repoId INTEGER NOT NULL, repoName TEXT NOT NULL, repoOwner TEXT NOT NULL, repoOwnerAvatarUrl TEXT NOT NULL, repoDescription TEXT, primaryLanguage TEXT, repoUrl TEXT NOT NULL, stargazersCount INTEGER NOT NULL, forksCount INTEGER NOT NULL, openIssuesCount INTEGER NOT NULL, isInstalled INTEGER NOT NULL DEFAULT 0, installedPackageName TEXT, latestVersion TEXT, latestReleaseUrl TEXT, starredAt INTEGER, addedAt INTEGER NOT NULL, lastSyncedAt INTEGER NOT NULL, PRIMARY KEY(repoId) ) """.trimIndent(), ) } } ================================================ FILE: core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/MIGRATION_3_4.kt ================================================ package zed.rainxch.core.data.local.db.migrations import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase val MIGRATION_3_4 = object : Migration(3, 4) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL( """ CREATE TABLE IF NOT EXISTS cache_entries ( `key` TEXT NOT NULL, jsonData TEXT NOT NULL, cachedAt INTEGER NOT NULL, expiresAt INTEGER NOT NULL, PRIMARY KEY(`key`) ) """.trimIndent(), ) } } ================================================ FILE: core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/MIGRATION_4_5.kt ================================================ package zed.rainxch.core.data.local.db.migrations import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase val MIGRATION_4_5 = object : Migration(4, 5) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE installed_apps ADD COLUMN signingFingerprint TEXT") } } ================================================ FILE: core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/MIGRATION_5_6.kt ================================================ package zed.rainxch.core.data.local.db.migrations import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase val MIGRATION_5_6 = object : Migration(5, 6) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL( """ CREATE TABLE IF NOT EXISTS seen_repos ( repoId INTEGER NOT NULL PRIMARY KEY, seenAt INTEGER NOT NULL ) """.trimIndent(), ) } } ================================================ FILE: core/data/src/androidMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.android.kt ================================================ package zed.rainxch.core.data.network import io.ktor.client.* import io.ktor.client.engine.okhttp.* import okhttp3.Credentials import zed.rainxch.core.domain.model.ProxyConfig import java.net.Authenticator import java.net.InetSocketAddress import java.net.PasswordAuthentication import java.net.Proxy import java.net.ProxySelector actual fun createPlatformHttpClient(proxyConfig: ProxyConfig): HttpClient { Authenticator.setDefault(null) return HttpClient(OkHttp) { engine { when (proxyConfig) { is ProxyConfig.None -> { proxy = Proxy.NO_PROXY } is ProxyConfig.System -> { config { proxySelector(ProxySelector.getDefault()) } } is ProxyConfig.Http -> { proxy = Proxy( Proxy.Type.HTTP, InetSocketAddress(proxyConfig.host, proxyConfig.port), ) if (proxyConfig.username != null) { config { proxyAuthenticator { _, response -> response.request .newBuilder() .header( "Proxy-Authorization", Credentials.basic( proxyConfig.username!!, proxyConfig.password.orEmpty(), ), ).build() } } } } is ProxyConfig.Socks -> { proxy = Proxy( Proxy.Type.SOCKS, InetSocketAddress(proxyConfig.host, proxyConfig.port), ) if (proxyConfig.username != null) { Authenticator.setDefault( object : Authenticator() { override fun getPasswordAuthentication(): PasswordAuthentication? { if (requestingHost == proxyConfig.host && requestingPort == proxyConfig.port ) { return PasswordAuthentication( proxyConfig.username, proxyConfig.password.orEmpty().toCharArray(), ) } return null } }, ) } } } } } } ================================================ FILE: core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidDownloader.kt ================================================ package zed.rainxch.core.data.services import co.touchlab.kermit.Logger import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.withContext import okhttp3.Call import okhttp3.Credentials import okhttp3.OkHttpClient import okhttp3.Request import zed.rainxch.core.data.network.ProxyManager import zed.rainxch.core.domain.model.DownloadProgress import zed.rainxch.core.domain.model.ProxyConfig import zed.rainxch.core.domain.network.Downloader import java.io.File import java.net.Authenticator import java.net.InetSocketAddress import java.net.PasswordAuthentication import java.net.Proxy import java.util.UUID import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit class AndroidDownloader( private val files: FileLocationsProvider, private val proxyManager: ProxyManager = ProxyManager, ) : Downloader { private val activeDownloads = ConcurrentHashMap() private val activeFileNames = ConcurrentHashMap() private fun buildClient(): OkHttpClient { Authenticator.setDefault(null) return OkHttpClient .Builder() .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(60, TimeUnit.SECONDS) .writeTimeout(60, TimeUnit.SECONDS) .apply { when (val config = proxyManager.currentProxyConfig.value) { is ProxyConfig.None -> { proxy(Proxy.NO_PROXY) } is ProxyConfig.System -> {} is ProxyConfig.Http -> { proxy(Proxy(Proxy.Type.HTTP, InetSocketAddress(config.host, config.port))) if (config.username != null && config.password != null) { proxyAuthenticator { _, response -> response.request .newBuilder() .header( "Proxy-Authorization", Credentials.basic(config.username!!, config.password!!), ).build() } } } is ProxyConfig.Socks -> { proxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress(config.host, config.port))) if (config.username != null && config.password != null) { Authenticator.setDefault( object : Authenticator() { override fun getPasswordAuthentication() = PasswordAuthentication( config.username, config.password!!.toCharArray(), ) }, ) } } } }.build() } override fun download( url: String, suggestedFileName: String?, ): Flow = flow { val client = buildClient() val dirPath = files.appDownloadsDir() val dir = File(dirPath) if (!dir.exists()) dir.mkdirs() val rawName = suggestedFileName?.takeIf { it.isNotBlank() } ?: url .substringAfterLast('/') .substringBefore('?') .substringBefore('#') .ifBlank { "asset-${UUID.randomUUID()}.apk" } val safeName = rawName.substringAfterLast('/').substringAfterLast('\\') require(safeName.isNotBlank() && safeName != "." && safeName != "..") { "Invalid file name: $rawName" } check(!activeFileNames.containsKey(safeName)) { "A download for '$safeName' is already in progress" } val downloadId = UUID.randomUUID().toString() val destination = File(dir, safeName) if (destination.exists()) { Logger.d { "Deleting existing file before download: ${destination.absolutePath}" } destination.delete() } Logger.d { "Starting download: $url (id=$downloadId)" } val request = Request.Builder().url(url).build() val call = client.newCall(request) activeDownloads[downloadId] = call activeFileNames[safeName] = downloadId try { call.execute().use { response -> if (!response.isSuccessful) { throw kotlinx.io.IOException("Unexpected code ${response.code}") } val body = response.body val contentLength = body.contentLength() val total = if (contentLength > 0) contentLength else null body.byteStream().use { input -> destination.outputStream().use { output -> val buffer = ByteArray(8192) var downloaded: Long = 0 var bytesRead: Int while (input.read(buffer).also { bytesRead = it } != -1) { output.write(buffer, 0, bytesRead) downloaded += bytesRead val percent = if (total != null) ((downloaded * 100L) / total).toInt() else null emit(DownloadProgress(downloaded, total, percent)) } } } if (destination.exists() && destination.length() > 0) { Logger.d { "Download complete: ${destination.absolutePath}" } val finalDownloaded = destination.length() val finalPercent = if (total != null) ((finalDownloaded * 100L) / total).toInt() else 100 emit(DownloadProgress(finalDownloaded, total, finalPercent)) } else { throw IllegalStateException("File not ready after download: ${destination.absolutePath}") } } } catch (e: Exception) { destination.delete() Logger.e(e) { "Download failed" } throw e } finally { activeDownloads.remove(downloadId) activeFileNames.remove(safeName) } }.flowOn(Dispatchers.IO) override suspend fun saveToFile( url: String, suggestedFileName: String?, ): String = withContext(Dispatchers.IO) { val rawName = suggestedFileName?.takeIf { it.isNotBlank() } ?: url .substringAfterLast('/') .substringBefore('?') .substringBefore('#') .ifBlank { "asset-${UUID.randomUUID()}.apk" } val safeName = rawName.substringAfterLast('/').substringAfterLast('\\') require(safeName.isNotBlank() && safeName != "." && safeName != "..") { "Invalid file name: $rawName" } val file = File(files.appDownloadsDir(), safeName) if (file.exists()) { Logger.d { "Deleting existing file before download: ${file.absolutePath}" } file.delete() } Logger.d { "saveToFile downloading file..." } download(url, suggestedFileName).collect { } file.absolutePath } override suspend fun getDownloadedFilePath(fileName: String): String? = withContext(Dispatchers.IO) { val file = File(files.appDownloadsDir(), fileName) if (file.exists() && file.length() > 0) { file.absolutePath } else { null } } override suspend fun cancelDownload(fileName: String): Boolean = withContext(Dispatchers.IO) { var cancelled = false var deleted = false val downloadId = activeFileNames[fileName] if (downloadId != null) { activeDownloads[downloadId]?.let { call: Call -> if (!call.isCanceled()) { call.cancel() cancelled = true } activeDownloads.remove(downloadId) } activeFileNames.remove(fileName) } val file = File(files.appDownloadsDir(), fileName) if (file.exists()) { deleted = file.delete() } cancelled || deleted } } ================================================ FILE: core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidFileLocationsProvider.kt ================================================ package zed.rainxch.core.data.services import android.content.Context import java.io.File class AndroidFileLocationsProvider( private val context: Context, ) : zed.rainxch.core.data.services.FileLocationsProvider { override fun appDownloadsDir(): String { val externalFilesRoot = context.getExternalFilesDir(null) val dir = File(externalFilesRoot, "ghs_downloads") if (!dir.exists()) dir.mkdirs() return dir.absolutePath } override fun userDownloadsDir(): String { return "" // No-op } override fun setExecutableIfNeeded(path: String) { // No-op on Android } override fun getCacheSizeBytes(): Long { val dir = File(appDownloadsDir()) return calculateDirSize(dir) } override fun clearCacheFiles(): Boolean { val dir = File(appDownloadsDir()) return deleteDirectoryContents(dir) } private fun calculateDirSize(dir: File): Long { if (!dir.exists()) return 0L var size = 0L dir.listFiles()?.forEach { file -> size += if (file.isDirectory) calculateDirSize(file) else file.length() } return size } private fun deleteDirectoryContents(dir: File): Boolean { if (!dir.exists()) return true var allDeleted = true dir.listFiles()?.forEach { file -> if (file.isDirectory) { if (!deleteDirectoryContents(file)) allDeleted = false if (!file.delete()) allDeleted = false } else { if (!file.delete()) allDeleted = false } } return allDeleted } } ================================================ FILE: core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidInstaller.kt ================================================ package zed.rainxch.core.data.services import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.net.Uri import android.os.Build import android.provider.Settings import androidx.core.content.FileProvider import androidx.core.net.toUri import co.touchlab.kermit.Logger import zed.rainxch.core.domain.model.AssetArchitectureMatcher import zed.rainxch.core.domain.model.GithubAsset import zed.rainxch.core.domain.model.SystemArchitecture import zed.rainxch.core.domain.system.Installer import zed.rainxch.core.domain.system.InstallerInfoExtractor import java.io.File class AndroidInstaller( private val context: Context, private val installerInfoExtractor: InstallerInfoExtractor, ) : Installer { override fun getApkInfoExtractor(): InstallerInfoExtractor = installerInfoExtractor override fun detectSystemArchitecture(): SystemArchitecture { val arch = Build.SUPPORTED_ABIS.firstOrNull() ?: return SystemArchitecture.UNKNOWN return when { arch.contains("arm64") || arch.contains("aarch64") -> SystemArchitecture.AARCH64 arch.contains("armeabi") -> SystemArchitecture.ARM arch.contains("x86_64") -> SystemArchitecture.X86_64 arch.contains("x86") -> SystemArchitecture.X86 else -> SystemArchitecture.UNKNOWN } } override fun isAssetInstallable(assetName: String): Boolean { val name = assetName.lowercase() if (!name.endsWith(".apk")) return false val systemArch = detectSystemArchitecture() return isArchitectureCompatible(name, systemArch) } private fun isArchitectureCompatible( assetName: String, systemArch: SystemArchitecture, ): Boolean = AssetArchitectureMatcher.isCompatible(assetName, systemArch) override fun choosePrimaryAsset(assets: List): GithubAsset? { if (assets.isEmpty()) return null val systemArch = detectSystemArchitecture() val compatibleAssets = assets.filter { asset -> isArchitectureCompatible(asset.name.lowercase(), systemArch) } val assetsToConsider = compatibleAssets.ifEmpty { assets } return assetsToConsider.maxByOrNull { asset -> val name = asset.name.lowercase() val archBoost = when (systemArch) { SystemArchitecture.X86_64 -> { if (AssetArchitectureMatcher.isExactMatch( name, SystemArchitecture.X86_64, ) ) { 10000 } else { 0 } } SystemArchitecture.AARCH64 -> { if (AssetArchitectureMatcher.isExactMatch( name, SystemArchitecture.AARCH64, ) ) { 10000 } else { 0 } } SystemArchitecture.X86 -> { if (AssetArchitectureMatcher.isExactMatch( name, SystemArchitecture.X86, ) ) { 10000 } else { 0 } } SystemArchitecture.ARM -> { if (AssetArchitectureMatcher.isExactMatch( name, SystemArchitecture.ARM, ) ) { 10000 } else { 0 } } SystemArchitecture.UNKNOWN -> { 0 } } archBoost + asset.size } } override suspend fun isSupported(extOrMime: String): Boolean { val ext = extOrMime.lowercase().removePrefix(".") return ext == "apk" } override suspend fun ensurePermissionsOrThrow(extOrMime: String) { val pm = context.packageManager if (!pm.canRequestPackageInstalls()) { val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply { data = "package:${context.packageName}".toUri() addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } context.startActivity(intent) throw IllegalStateException("Please enable 'Install unknown apps' for this app in Settings and try again.") } } override suspend fun install( filePath: String, extOrMime: String, ) { val file = File(filePath) if (!file.exists()) { throw IllegalStateException("APK file not found: $filePath") } Logger.d { "Installing APK: $filePath" } val authority = "${context.packageName}.fileprovider" val fileUri: Uri = FileProvider.getUriForFile(context, authority, file) val intent = Intent(Intent.ACTION_VIEW).apply { setDataAndType(fileUri, "application/vnd.android.package-archive") addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } if (intent.resolveActivity(context.packageManager) != null) { context.startActivity(intent) Logger.d { "APK installation intent launched" } } else { throw IllegalStateException("No installer available on this device") } } override fun uninstall(packageName: String) { Logger.d { "Requesting uninstall for: $packageName" } val intent = Intent(Intent.ACTION_DELETE).apply { data = "package:$packageName".toUri() addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } try { context.startActivity(intent) } catch (e: Exception) { Logger.w { "Failed to start uninstall for $packageName: ${e.message}" } } } override fun isObtainiumInstalled(): Boolean = try { context.packageManager.getPackageInfo("dev.imranr.obtainium.fdroid", 0) true } catch (e: Exception) { try { context.packageManager.getPackageInfo("dev.imranr.obtainium", 0) true } catch (e: Exception) { false } } override fun openInObtainium( repoOwner: String, repoName: String, onOpenInstaller: () -> Unit, ) { val obtainiumUrl = "obtainium://add/https://github.com/$repoOwner/$repoName" val intent = Intent(Intent.ACTION_VIEW).apply { data = obtainiumUrl.toUri() addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } try { context.startActivity(intent) } catch (_: ActivityNotFoundException) { onOpenInstaller() } } override fun isAppManagerInstalled(): Boolean = try { context.packageManager.getPackageInfo("io.github.muntashirakon.AppManager", 0) true } catch (e: Exception) { false } override fun openApp(packageName: String): Boolean { val launchIntent = context.packageManager.getLaunchIntentForPackage(packageName) return if (launchIntent != null) { try { launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) context.startActivity(launchIntent) true } catch (e: ActivityNotFoundException) { Logger.w { "Failed to launch $packageName: ${e.message}" } false } } else { Logger.w { "No launch intent found for $packageName" } false } } override fun openWithExternalInstaller(filePath: String) { val file = File(filePath) if (!file.exists()) { throw IllegalStateException("APK file not found: $filePath") } Logger.d { "Opening APK with external installer: $filePath" } val authority = "${context.packageName}.fileprovider" val fileUri: Uri = FileProvider.getUriForFile(context, authority, file) val intent = Intent(Intent.ACTION_VIEW).apply { setDataAndType(fileUri, "application/vnd.android.package-archive") addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } val chooser = Intent.createChooser(intent, null).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } context.startActivity(chooser) } override fun openInAppManager( filePath: String, onOpenInstaller: () -> Unit, ) { val file = File(filePath) if (!file.exists()) { throw IllegalStateException("APK file not found: $filePath") } Logger.d { "Opening APK in AppManager: $filePath" } val authority = "${context.packageName}.fileprovider" val fileUri: Uri = FileProvider.getUriForFile(context, authority, file) val intent = Intent(Intent.ACTION_VIEW).apply { setDataAndType(fileUri, "application/vnd.android.package-archive") setPackage("io.github.muntashirakon.AppManager") addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } try { context.startActivity(intent) Logger.d { "APK opened in AppManager" } } catch (_: ActivityNotFoundException) { onOpenInstaller() } } } ================================================ FILE: core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidInstallerInfoExtractor.kt ================================================ package zed.rainxch.core.data.services import android.content.Context import android.content.pm.PackageManager import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES import android.os.Build import co.touchlab.kermit.Logger import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import zed.rainxch.core.domain.model.ApkPackageInfo import zed.rainxch.core.domain.system.InstallerInfoExtractor import java.io.File import java.security.MessageDigest class AndroidInstallerInfoExtractor( private val context: Context, ) : InstallerInfoExtractor { override suspend fun extractPackageInfo(filePath: String): ApkPackageInfo? = withContext(Dispatchers.IO) { try { val packageManager = context.packageManager val flags = PackageManager.GET_META_DATA or PackageManager.GET_ACTIVITIES or GET_SIGNING_CERTIFICATES val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { packageManager.getPackageArchiveInfo( filePath, PackageManager.PackageInfoFlags.of(flags.toLong()), ) } else { @Suppress("DEPRECATION") packageManager.getPackageArchiveInfo(filePath, flags) } if (packageInfo == null) { Logger.e { "Failed to parse APK at $filePath, file exists: ${ File( filePath, ).exists() }, size: ${File(filePath).length()}" } return@withContext null } val appInfo = packageInfo.applicationInfo appInfo?.sourceDir = filePath appInfo?.publicSourceDir = filePath val appName = appInfo?.let { packageManager.getApplicationLabel(it) }.toString() val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { packageInfo.longVersionCode } else { @Suppress("DEPRECATION") packageInfo.versionCode.toLong() } val fingerprint: String? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { val sigInfo = packageInfo.signingInfo val certs = if (sigInfo?.hasMultipleSigners() == true) { sigInfo.apkContentsSigners } else { sigInfo?.signingCertificateHistory } certs?.firstOrNull()?.toByteArray()?.let { certBytes -> MessageDigest .getInstance("SHA-256") .digest(certBytes) .joinToString(":") { "%02X".format(it) } } } else { @Suppress("DEPRECATION") val legacyInfo = packageManager.getPackageArchiveInfo( filePath, PackageManager.GET_SIGNATURES, ) @Suppress("DEPRECATION") legacyInfo?.signatures?.firstOrNull()?.toByteArray()?.let { certBytes -> MessageDigest .getInstance("SHA-256") .digest(certBytes) .joinToString(":") { "%02X".format(it) } } } ApkPackageInfo( appName = appName, packageName = packageInfo.packageName, versionName = packageInfo.versionName ?: "unknown", versionCode = versionCode, signingFingerprint = fingerprint, ) } catch (e: Exception) { Logger.e { "Failed to extract APK info: ${e.message}, file: $filePath" } null } } } ================================================ FILE: core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidLocalizationManager.kt ================================================ package zed.rainxch.core.data.services import java.util.Locale class AndroidLocalizationManager : zed.rainxch.core.data.services.LocalizationManager { override fun getCurrentLanguageCode(): String { val locale = Locale.getDefault() val language = locale.language val country = locale.country return if (country.isNotEmpty()) { "$language-$country" } else { language } } override fun getPrimaryLanguageCode(): String = Locale.getDefault().language } ================================================ FILE: core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidPackageMonitor.kt ================================================ package zed.rainxch.core.data.services import android.content.Context import android.content.pm.ApplicationInfo import android.content.pm.PackageManager import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES import android.os.Build import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import zed.rainxch.core.domain.model.DeviceApp import zed.rainxch.core.domain.model.SystemPackageInfo import zed.rainxch.core.domain.system.PackageMonitor import java.security.MessageDigest class AndroidPackageMonitor( context: Context, ) : PackageMonitor { private val packageManager: PackageManager = context.packageManager override suspend fun isPackageInstalled(packageName: String): Boolean = getInstalledPackageInfo(packageName) != null override suspend fun getInstalledPackageInfo(packageName: String): SystemPackageInfo? = withContext(Dispatchers.IO) { runCatching { val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { GET_SIGNING_CERTIFICATES.toLong() } else { @Suppress("DEPRECATION") PackageManager.GET_SIGNATURES.toLong() } val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { packageManager.getPackageInfo( packageName, PackageManager.PackageInfoFlags.of(flags), ) } else { @Suppress("DEPRECATION") packageManager.getPackageInfo(packageName, flags.toInt()) } val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { packageInfo.longVersionCode } else { @Suppress("DEPRECATION") packageInfo.versionCode.toLong() } val signingFingerprint: String? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { val sigInfo = packageInfo.signingInfo val certs = if (sigInfo?.hasMultipleSigners() == true) { sigInfo.apkContentsSigners } else { sigInfo?.signingCertificateHistory } certs?.firstOrNull()?.toByteArray()?.let { certBytes -> MessageDigest .getInstance("SHA-256") .digest(certBytes) .joinToString(":") { "%02X".format(it) } } } else { @Suppress("DEPRECATION") packageInfo.signatures?.firstOrNull()?.toByteArray()?.let { certBytes -> MessageDigest .getInstance("SHA-256") .digest(certBytes) .joinToString(":") { "%02X".format(it) } } } SystemPackageInfo( packageName = packageInfo.packageName, versionName = packageInfo.versionName ?: "unknown", versionCode = versionCode, isInstalled = true, signingFingerprint = signingFingerprint, ) }.getOrNull() } override suspend fun getAllInstalledPackageNames(): Set = withContext(Dispatchers.IO) { val packages = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { packageManager.getInstalledPackages(PackageManager.PackageInfoFlags.of(0L)) } else { @Suppress("DEPRECATION") packageManager.getInstalledPackages(0) } packages.map { it.packageName }.toSet() } override suspend fun getAllInstalledApps(): List = withContext(Dispatchers.IO) { val packages = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { packageManager.getInstalledPackages(PackageManager.PackageInfoFlags.of(0L)) } else { @Suppress("DEPRECATION") packageManager.getInstalledPackages(0) } packages .filter { pkg -> val isSystemApp = (pkg.applicationInfo?.flags ?: 0) and ApplicationInfo.FLAG_SYSTEM != 0 val isUpdatedSystem = (pkg.applicationInfo?.flags ?: 0) and ApplicationInfo.FLAG_UPDATED_SYSTEM_APP != 0 !isSystemApp || isUpdatedSystem }.map { pkg -> val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { pkg.longVersionCode } else { @Suppress("DEPRECATION") pkg.versionCode.toLong() } DeviceApp( packageName = pkg.packageName, appName = pkg.applicationInfo?.loadLabel(packageManager)?.toString() ?: pkg.packageName, versionName = pkg.versionName, versionCode = versionCode, signingFingerprint = null, ) }.sortedBy { it.appName.lowercase() } } } ================================================ FILE: core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidUpdateScheduleManager.kt ================================================ package zed.rainxch.core.data.services import android.content.Context import zed.rainxch.core.domain.system.UpdateScheduleManager class AndroidUpdateScheduleManager( private val context: Context, ) : UpdateScheduleManager { override fun reschedule(intervalHours: Long) { UpdateScheduler.reschedule(context, intervalHours) } } ================================================ FILE: core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AutoUpdateWorker.kt ================================================ package zed.rainxch.core.data.services import android.Manifest import android.annotation.SuppressLint import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.content.pm.ServiceInfo import android.os.Build import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import androidx.work.CoroutineWorker import androidx.work.ForegroundInfo import androidx.work.WorkerParameters import co.touchlab.kermit.Logger import kotlinx.coroutines.flow.first import org.koin.core.component.KoinComponent import org.koin.core.component.inject import zed.rainxch.core.data.services.shizuku.ShizukuServiceManager import zed.rainxch.core.data.services.shizuku.model.ShizukuStatus import zed.rainxch.core.domain.model.InstalledApp import zed.rainxch.core.domain.model.InstallerType import zed.rainxch.core.domain.network.Downloader import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.repository.TweaksRepository import zed.rainxch.core.domain.system.Installer /** * Background worker that automatically downloads and silently installs * available updates via Shizuku. * * Only runs when auto-update is enabled AND Shizuku installer is selected and READY. * Falls back gracefully: if Shizuku becomes unavailable mid-update, remaining apps * are skipped and a notification is shown for manual update. */ class AutoUpdateWorker( context: Context, params: WorkerParameters, ) : CoroutineWorker(context, params), KoinComponent { private val installedAppsRepository: InstalledAppsRepository by inject() private val installer: Installer by inject() private val downloader: Downloader by inject() private val tweaksRepository: TweaksRepository by inject() private val shizukuServiceManager: ShizukuServiceManager by inject() override suspend fun doWork(): Result { return try { Logger.i { "AutoUpdateWorker: Starting auto-update" } val autoUpdateEnabled = tweaksRepository.getAutoUpdateEnabled().first() val installerType = tweaksRepository.getInstallerType().first() shizukuServiceManager.refreshStatus() val shizukuReady = shizukuServiceManager.status.value == ShizukuStatus.READY if (!autoUpdateEnabled || installerType != InstallerType.SHIZUKU || !shizukuReady) { Logger.i { "AutoUpdateWorker: Conditions not met (autoUpdate=$autoUpdateEnabled, installer=$installerType, shizuku=$shizukuReady), skipping" } return Result.success() } val appsWithUpdates = installedAppsRepository.getAppsWithUpdates().first() if (appsWithUpdates.isEmpty()) { Logger.d { "AutoUpdateWorker: No apps need updating" } return Result.success() } setForeground(createForegroundInfo("Updating apps...", 0, appsWithUpdates.size)) val successfulApps = mutableListOf() val failedApps = mutableListOf() appsWithUpdates.forEachIndexed { index, app -> setForeground( createForegroundInfo( "Updating ${app.appName}...", index + 1, appsWithUpdates.size, ), ) try { updateApp(app) successfulApps.add(app.appName) Logger.i { "AutoUpdateWorker: Successfully updated ${app.appName}" } } catch (e: Exception) { failedApps.add(app.appName) Logger.e { "AutoUpdateWorker: Failed to update ${app.appName}: ${e.message}" } try { installedAppsRepository.updatePendingStatus(app.packageName, false) } catch (clearEx: Exception) { Logger.e { "AutoUpdateWorker: Failed to clear pending status: ${clearEx.message}" } } } } showSummaryNotification(successfulApps, failedApps) Logger.i { "AutoUpdateWorker: Completed. Success: ${successfulApps.size}, Failed: ${failedApps.size}" } Result.success() } catch (e: Exception) { Logger.e { "AutoUpdateWorker: Fatal error: ${e.message}" } if (runAttemptCount < 2) { Result.retry() } else { Result.failure() } } } private suspend fun updateApp(app: InstalledApp) { val assetUrl = app.latestAssetUrl ?: throw IllegalStateException("No asset URL for ${app.appName}") val assetName = app.latestAssetName ?: throw IllegalStateException("No asset name for ${app.appName}") val latestVersion = app.latestVersion ?: throw IllegalStateException("No latest version for ${app.appName}") val ext = assetName.substringAfterLast('.', "").lowercase() val existingPath = downloader.getDownloadedFilePath(assetName) if (existingPath != null) { val file = java.io.File(existingPath) try { val apkInfo = installer.getApkInfoExtractor().extractPackageInfo(existingPath) val normalizedExisting = apkInfo?.versionName?.removePrefix("v")?.removePrefix("V") ?: "" val normalizedLatest = latestVersion.removePrefix("v").removePrefix("V") if (normalizedExisting != normalizedLatest) { file.delete() Logger.d { "AutoUpdateWorker: Deleted mismatched existing file for ${app.appName}" } } } catch (_: Exception) { file.delete() Logger.d { "AutoUpdateWorker: Deleted unextractable existing file for ${app.appName}" } } } Logger.d { "AutoUpdateWorker: Downloading $assetName for ${app.appName}" } downloader.download(assetUrl, assetName).collect { /* consume flow to completion */ } val filePath = downloader.getDownloadedFilePath(assetName) ?: throw IllegalStateException("Downloaded file not found for ${app.appName}") val apkInfo = installer.getApkInfoExtractor().extractPackageInfo(filePath) ?: throw IllegalStateException("Failed to extract APK info for ${app.appName}") // Validate package name matches if (apkInfo.packageName != app.packageName) { Logger.e { "AutoUpdateWorker: Package name mismatch for ${app.appName}! " + "Expected: ${app.packageName}, got: ${apkInfo.packageName}. " + "Skipping auto-update." } throw IllegalStateException( "Package name mismatch for ${app.appName}: expected ${app.packageName}, got ${apkInfo.packageName}", ) } val currentApp = installedAppsRepository.getAppByPackage(app.packageName) if (currentApp?.signingFingerprint != null) { val expected = currentApp.signingFingerprint!!.trim().uppercase() val actual = apkInfo.signingFingerprint?.trim()?.uppercase() if (actual == null || expected != actual) { Logger.e { "AutoUpdateWorker: Signing key mismatch for ${app.appName}! " + "Expected: ${currentApp.signingFingerprint}, got: ${apkInfo.signingFingerprint}. " + "Skipping auto-update." } throw IllegalStateException( "Signing fingerprint verification failed for ${app.appName}, blocking auto-update", ) } installedAppsRepository.updateApp( currentApp.copy( isPendingInstall = true, latestVersion = latestVersion, latestAssetName = assetName, latestAssetUrl = assetUrl, latestVersionName = apkInfo.versionName, latestVersionCode = apkInfo.versionCode, ), ) Logger.d { "AutoUpdateWorker: Installing ${app.appName} via Shizuku" } try { installer.install(filePath, ext) } catch (e: Exception) { installedAppsRepository.updatePendingStatus(app.packageName, false) throw e } Logger.d { "AutoUpdateWorker: Install command completed for ${app.appName}, waiting for system confirmation via broadcast" } } } private fun createForegroundInfo( message: String, current: Int, total: Int, ): ForegroundInfo { val builder = NotificationCompat .Builder(applicationContext, UPDATE_SERVICE_CHANNEL_ID) .setSmallIcon(android.R.drawable.stat_sys_download) .setContentTitle("GitHub Store") .setContentText(message) .setPriority(NotificationCompat.PRIORITY_LOW) .setOngoing(true) .setSilent(true) if (total > 0) { builder.setProgress(total, current, false) } val notification = builder.build() return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { ForegroundInfo( FOREGROUND_NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, ) } else { ForegroundInfo(FOREGROUND_NOTIFICATION_ID, notification) } } @SuppressLint("MissingPermission") private fun showSummaryNotification( successfulApps: List, failedApps: List, ) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val granted = ContextCompat.checkSelfPermission( applicationContext, Manifest.permission.POST_NOTIFICATIONS, ) == PackageManager.PERMISSION_GRANTED if (!granted) return } if (successfulApps.isEmpty() && failedApps.isEmpty()) return val title = when { failedApps.isEmpty() -> "${successfulApps.size} app${if (successfulApps.size > 1) "s" else ""} updated" successfulApps.isEmpty() -> "Failed to update ${failedApps.size} app${if (failedApps.size > 1) "s" else ""}" else -> "${successfulApps.size} updated, ${failedApps.size} failed" } val text = when { failedApps.isEmpty() -> successfulApps.joinToString(", ") successfulApps.isEmpty() -> failedApps.joinToString(", ") else -> "Updated: ${successfulApps.joinToString(", ")}. Failed: ${ failedApps.joinToString( ", ", ) }" } val launchIntent = applicationContext.packageManager .getLaunchIntentForPackage(applicationContext.packageName) ?.apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP } val pendingIntent = launchIntent?.let { PendingIntent.getActivity( applicationContext, 0, it, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) } val notification = NotificationCompat .Builder(applicationContext, UPDATES_CHANNEL_ID) .setSmallIcon( if (failedApps.isEmpty()) { android.R.drawable.stat_sys_download_done } else { android.R.drawable.stat_notify_error }, ).setContentTitle(title) .setContentText(text) .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setContentIntent(pendingIntent) .setAutoCancel(true) .build() NotificationManagerCompat .from(applicationContext) .notify(SUMMARY_NOTIFICATION_ID, notification) } companion object { const val WORK_NAME = "github_store_auto_update" private const val UPDATES_CHANNEL_ID = "app_updates" private const val UPDATE_SERVICE_CHANNEL_ID = "update_service" private const val FOREGROUND_NOTIFICATION_ID = 1004 private const val SUMMARY_NOTIFICATION_ID = 1005 } } ================================================ FILE: core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/BootReceiver.kt ================================================ package zed.rainxch.core.data.services import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import co.touchlab.kermit.Logger /** * Reschedules periodic update checks after device reboot. * Registered statically in AndroidManifest.xml. */ class BootReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent?) { if (intent?.action == Intent.ACTION_BOOT_COMPLETED) { Logger.i { "BootReceiver: Device booted, scheduling update checks" } UpdateScheduler.schedule(context) } } } ================================================ FILE: core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt ================================================ package zed.rainxch.core.data.services import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.system.PackageMonitor /** * Listens for package install/replace/remove broadcasts to update tracked app state. * * Registered both statically (manifest — works when process is dead, e.g. after * Shizuku silent install) and dynamically (GithubStoreApp — immediate in-process delivery). * * Uses [KoinComponent] for the no-arg constructor path (manifest-registered). * The constructor with explicit dependencies is used for dynamic registration. */ class PackageEventReceiver() : BroadcastReceiver(), KoinComponent { private val installedAppsRepositoryKoin: InstalledAppsRepository by inject() private val packageMonitorKoin: PackageMonitor by inject() // Explicitly provided dependencies (dynamic registration path) private var explicitRepository: InstalledAppsRepository? = null private var explicitMonitor: PackageMonitor? = null private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) constructor( installedAppsRepository: InstalledAppsRepository, packageMonitor: PackageMonitor, ) : this() { this.explicitRepository = installedAppsRepository this.explicitMonitor = packageMonitor } private fun getRepository(): InstalledAppsRepository = explicitRepository ?: installedAppsRepositoryKoin private fun getMonitor(): PackageMonitor = explicitMonitor ?: packageMonitorKoin override fun onReceive( context: Context?, intent: Intent?, ) { val packageName = intent?.data?.schemeSpecificPart ?: return Logger.d { "PackageEventReceiver: ${intent.action} for $packageName" } try { when (intent.action) { Intent.ACTION_PACKAGE_ADDED, Intent.ACTION_PACKAGE_REPLACED, -> { scope.launch { onPackageInstalled(packageName) } } Intent.ACTION_PACKAGE_FULLY_REMOVED -> { scope.launch { onPackageRemoved(packageName) } } } } catch (e: Exception) { Logger.e { "PackageEventReceiver: Failed to handle ${intent.action}: ${e.message}" } } } private suspend fun onPackageInstalled(packageName: String) { try { val repo = getRepository() val monitor = getMonitor() val app = repo.getAppByPackage(packageName) ?: return if (app.isPendingInstall) { val systemInfo = monitor.getInstalledPackageInfo(packageName) if (systemInfo != null) { val expectedVersionCode = app.latestVersionCode ?: 0L val wasActuallyUpdated = expectedVersionCode > 0L && systemInfo.versionCode >= expectedVersionCode if (wasActuallyUpdated) { repo.updateAppVersion( packageName = packageName, newTag = app.latestVersion ?: systemInfo.versionName, newAssetName = app.latestAssetName ?: "", newAssetUrl = app.latestAssetUrl ?: "", newVersionName = systemInfo.versionName, newVersionCode = systemInfo.versionCode, signingFingerprint = app.signingFingerprint, ) repo.updatePendingStatus(packageName, false) Logger.i { "Update confirmed via broadcast: $packageName (v${systemInfo.versionName})" } } else { repo.updateApp( app.copy( isPendingInstall = false, installedVersionName = systemInfo.versionName, installedVersionCode = systemInfo.versionCode, isUpdateAvailable = ( app.latestVersionCode ?: 0L ) > systemInfo.versionCode, ), ) Logger.i { "Package replaced but not updated to target: $packageName " + "(system: v${systemInfo.versionName}/${systemInfo.versionCode}, " + "target: v${app.latestVersionName}/${app.latestVersionCode})" } } } else { repo.updatePendingStatus(packageName, false) Logger.i { "Resolved pending install via broadcast (no system info): $packageName" } } } else { val systemInfo = monitor.getInstalledPackageInfo(packageName) if (systemInfo != null) { repo.updateApp( app.copy( installedVersionName = systemInfo.versionName, installedVersionCode = systemInfo.versionCode, ), ) Logger.d { "Updated version info via broadcast: $packageName (v${systemInfo.versionName})" } } } } catch (e: Exception) { Logger.e { "PackageEventReceiver error for $packageName: ${e.message}" } } } private suspend fun onPackageRemoved(packageName: String) { try { getRepository().deleteInstalledApp(packageName) Logger.i { "Removed uninstalled app via broadcast: $packageName" } } catch (e: Exception) { Logger.e { "PackageEventReceiver remove error for $packageName: ${e.message}" } } } companion object { fun createIntentFilter(): IntentFilter = IntentFilter().apply { addAction(Intent.ACTION_PACKAGE_ADDED) addAction(Intent.ACTION_PACKAGE_REPLACED) addAction(Intent.ACTION_PACKAGE_FULLY_REMOVED) addDataScheme("package") } } } ================================================ FILE: core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateCheckWorker.kt ================================================ package zed.rainxch.core.data.services import android.Manifest import android.annotation.SuppressLint import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.content.pm.ServiceInfo import android.os.Build import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import androidx.work.CoroutineWorker import androidx.work.ForegroundInfo import androidx.work.WorkerParameters import co.touchlab.kermit.Logger import kotlinx.coroutines.flow.first import org.koin.core.component.KoinComponent import org.koin.core.component.inject import zed.rainxch.core.domain.model.InstallerType import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.repository.TweaksRepository import zed.rainxch.core.domain.use_cases.SyncInstalledAppsUseCase /** * Periodic background worker that checks all tracked installed apps for available updates. * * Runs via WorkManager on a configurable schedule (default: every 6 hours). * First syncs app state with the system package manager, then checks each * tracked app's GitHub repository for new releases. * Shows a notification when updates are found, or triggers auto-update * if Shizuku silent install is enabled and auto-update preference is on. */ class UpdateCheckWorker( context: Context, params: WorkerParameters, ) : CoroutineWorker(context, params), KoinComponent { private val installedAppsRepository: InstalledAppsRepository by inject() private val syncInstalledAppsUseCase: SyncInstalledAppsUseCase by inject() private val tweaksRepository: TweaksRepository by inject() override suspend fun doWork(): Result = try { Logger.i { "UpdateCheckWorker: Starting periodic update check" } // Run as foreground service to prevent OS from killing the worker setForeground(createForegroundInfo("Checking for updates...")) // First sync installed apps state with system val syncResult = syncInstalledAppsUseCase() if (syncResult.isFailure) { Logger.w { "UpdateCheckWorker: Sync had issues: ${syncResult.exceptionOrNull()?.message}" } } // Check all tracked apps for updates installedAppsRepository.checkAllForUpdates() val appsWithUpdates = installedAppsRepository.getAppsWithUpdates().first() if (appsWithUpdates.isNotEmpty()) { // Check if auto-update via Shizuku is enabled val autoUpdateEnabled = tweaksRepository.getAutoUpdateEnabled().first() val installerType = tweaksRepository.getInstallerType().first() if (autoUpdateEnabled && installerType == InstallerType.SHIZUKU) { Logger.i { "UpdateCheckWorker: Auto-update enabled with Shizuku, scheduling AutoUpdateWorker for ${appsWithUpdates.size} apps" } UpdateScheduler.scheduleAutoUpdate(applicationContext) } else { // Show notification for manual update showUpdateNotification(appsWithUpdates) } } else { Logger.d { "UpdateCheckWorker: No updates available" } } Logger.i { "UpdateCheckWorker: Periodic update check completed successfully" } Result.success() } catch (e: Exception) { Logger.e { "UpdateCheckWorker: Update check failed: ${e.message}" } if (runAttemptCount < 3) { Result.retry() } else { Result.failure() } } private fun createForegroundInfo(message: String): ForegroundInfo { val notification = NotificationCompat .Builder(applicationContext, UPDATE_SERVICE_CHANNEL_ID) .setSmallIcon(android.R.drawable.stat_notify_sync) .setContentTitle("GitHub Store") .setContentText(message) .setPriority(NotificationCompat.PRIORITY_LOW) .setOngoing(true) .setSilent(true) .build() return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { ForegroundInfo( FOREGROUND_NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, ) } else { ForegroundInfo(FOREGROUND_NOTIFICATION_ID, notification) } } @SuppressLint("MissingPermission") // Permission checked at runtime before notify() private suspend fun showUpdateNotification(appsWithUpdates: List) { // Check notification permission for API 33+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val granted = ContextCompat.checkSelfPermission( applicationContext, Manifest.permission.POST_NOTIFICATIONS, ) == PackageManager.PERMISSION_GRANTED if (!granted) { Logger.w { "UpdateCheckWorker: POST_NOTIFICATIONS permission not granted, skipping notification" } return } } val title = if (appsWithUpdates.size == 1) { "${appsWithUpdates.first().appName} update available" } else { "${appsWithUpdates.size} app updates available" } val text = if (appsWithUpdates.size == 1) { val app = appsWithUpdates.first() "${app.installedVersion} → ${app.latestVersion}" } else { appsWithUpdates.joinToString(", ") { it.appName } } val launchIntent = applicationContext.packageManager .getLaunchIntentForPackage(applicationContext.packageName) ?.apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP } val pendingIntent = launchIntent?.let { PendingIntent.getActivity( applicationContext, 0, it, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) } val notification = NotificationCompat .Builder(applicationContext, UPDATES_CHANNEL_ID) .setSmallIcon(android.R.drawable.stat_sys_download_done) .setContentTitle(title) .setContentText(text) .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setContentIntent(pendingIntent) .setAutoCancel(true) .build() NotificationManagerCompat.from(applicationContext).notify(NOTIFICATION_ID, notification) Logger.i { "UpdateCheckWorker: Showed notification for ${appsWithUpdates.size} updates" } } companion object { const val WORK_NAME = "github_store_update_check" private const val UPDATES_CHANNEL_ID = "app_updates" private const val UPDATE_SERVICE_CHANNEL_ID = "update_service" private const val NOTIFICATION_ID = 1001 private const val FOREGROUND_NOTIFICATION_ID = 1003 } } ================================================ FILE: core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateScheduler.kt ================================================ package zed.rainxch.core.data.services import android.content.Context import androidx.work.BackoffPolicy import androidx.work.Constraints import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ExistingWorkPolicy import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import co.touchlab.kermit.Logger import java.util.concurrent.TimeUnit object UpdateScheduler { private const val DEFAULT_INTERVAL_HOURS = 6L private const val IMMEDIATE_CHECK_WORK_NAME = "github_store_immediate_update_check" fun schedule( context: Context, intervalHours: Long = DEFAULT_INTERVAL_HOURS, ) { val constraints = Constraints .Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .build() val request = PeriodicWorkRequestBuilder( repeatInterval = intervalHours, repeatIntervalTimeUnit = TimeUnit.HOURS, ).setConstraints(constraints) .setBackoffCriteria( BackoffPolicy.EXPONENTIAL, 30, TimeUnit.MINUTES, ).build() WorkManager .getInstance(context) .enqueueUniquePeriodicWork( uniqueWorkName = UpdateCheckWorker.WORK_NAME, existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.KEEP, request = request, ) val immediateRequest = OneTimeWorkRequestBuilder() .setConstraints(constraints) .setInitialDelay(1, TimeUnit.MINUTES) .build() WorkManager .getInstance(context) .enqueueUniqueWork( IMMEDIATE_CHECK_WORK_NAME, ExistingWorkPolicy.REPLACE, immediateRequest, ) Logger.i { "UpdateScheduler: Scheduled periodic update check every ${intervalHours}h + immediate check" } } fun reschedule( context: Context, intervalHours: Long, ) { val constraints = Constraints .Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .build() val request = PeriodicWorkRequestBuilder( repeatInterval = intervalHours, repeatIntervalTimeUnit = TimeUnit.HOURS, ).setConstraints(constraints) .setBackoffCriteria( BackoffPolicy.EXPONENTIAL, 30, TimeUnit.MINUTES, ).build() WorkManager .getInstance(context) .enqueueUniquePeriodicWork( uniqueWorkName = UpdateCheckWorker.WORK_NAME, existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.UPDATE, request = request, ) Logger.i { "UpdateScheduler: Rescheduled periodic update check to every ${intervalHours}h" } } fun scheduleAutoUpdate(context: Context) { val constraints = Constraints .Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .build() val request = OneTimeWorkRequestBuilder() .setConstraints(constraints) .setBackoffCriteria( BackoffPolicy.EXPONENTIAL, 15, TimeUnit.MINUTES, ).build() WorkManager .getInstance(context) .enqueueUniqueWork( AutoUpdateWorker.WORK_NAME, ExistingWorkPolicy.KEEP, request, ) Logger.i { "UpdateScheduler: Scheduled auto-update worker" } } fun cancel(context: Context) { WorkManager .getInstance(context) .cancelUniqueWork(UpdateCheckWorker.WORK_NAME) WorkManager .getInstance(context) .cancelUniqueWork(IMMEDIATE_CHECK_WORK_NAME) WorkManager .getInstance(context) .cancelUniqueWork(AutoUpdateWorker.WORK_NAME) Logger.i { "UpdateScheduler: Cancelled all update work" } } } ================================================ FILE: core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/shizuku/AndroidInstallerStatusProvider.kt ================================================ package zed.rainxch.core.data.services.shizuku import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import zed.rainxch.core.data.services.shizuku.model.ShizukuStatus import zed.rainxch.core.domain.model.ShizukuAvailability import zed.rainxch.core.domain.system.InstallerStatusProvider /** * Android implementation of [InstallerStatusProvider]. * Maps [ShizukuServiceManager.status] to the platform-agnostic [ShizukuAvailability] enum. */ class AndroidInstallerStatusProvider( private val shizukuServiceManager: ShizukuServiceManager, scope: CoroutineScope, ) : InstallerStatusProvider { override val shizukuAvailability: StateFlow = shizukuServiceManager.status .map { status -> when (status) { ShizukuStatus.NOT_INSTALLED -> ShizukuAvailability.UNAVAILABLE ShizukuStatus.NOT_RUNNING -> ShizukuAvailability.NOT_RUNNING ShizukuStatus.PERMISSION_NEEDED -> ShizukuAvailability.PERMISSION_NEEDED ShizukuStatus.READY -> ShizukuAvailability.READY } }.stateIn( scope = scope, started = SharingStarted.Eagerly, initialValue = ShizukuAvailability.UNAVAILABLE, ) override fun requestShizukuPermission() { shizukuServiceManager.requestPermission() } } ================================================ FILE: core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/shizuku/ShizukuInstallerServiceImpl.kt ================================================ package zed.rainxch.core.data.services.shizuku import android.os.ParcelFileDescriptor import java.io.BufferedReader import java.io.InputStreamReader import java.util.concurrent.TimeUnit /** * Shizuku UserService implementation that runs in a privileged process (shell/root). * Provides silent package install/uninstall via `pm` shell commands. * * This class runs in Shizuku's process, NOT in the app's process. * It has shell-level (UID 2000) or root-level (UID 0) privileges. * * Uses `pm install` with stdin pipe for install, `pm uninstall` for uninstall. * This is the most reliable approach — avoids fragile reflection on hidden * IPackageInstaller/IPackageInstallerSession/IIntentSender APIs. * * MUST have a default no-arg constructor for Shizuku's UserService framework. */ class ShizukuInstallerServiceImpl() : IShizukuInstallerService.Stub() { companion object { private const val TAG = "ShizukuService" private const val STATUS_SUCCESS = 0 private const val STATUS_FAILURE = -1 private const val INSTALL_TIMEOUT_SECONDS = 120L private const val UNINSTALL_TIMEOUT_SECONDS = 30L private fun log(msg: String) = android.util.Log.d(TAG, msg) private fun logW(msg: String) = android.util.Log.w(TAG, msg) private fun logE(msg: String, e: Throwable? = null) = android.util.Log.e(TAG, msg, e) } override fun installPackage(pfd: ParcelFileDescriptor, fileSize: Long): Int { log("installPackage() called — fileSize=$fileSize") log("Process UID: ${android.os.Process.myUid()}, PID: ${android.os.Process.myPid()}") return try { // Use "pm install -S " which reads the APK from stdin val command = arrayOf("pm", "install", "-S", fileSize.toString()) log("Executing: ${command.joinToString(" ")}") val process = Runtime.getRuntime().exec(command) // Pipe the APK from the ParcelFileDescriptor to pm's stdin val writeThread = Thread { try { ParcelFileDescriptor.AutoCloseInputStream(pfd).use { input -> process.outputStream.use { output -> val buffer = ByteArray(65536) var bytesWritten = 0L var read: Int while (input.read(buffer).also { read = it } != -1) { output.write(buffer, 0, read) bytesWritten += read } output.flush() log("APK piped to pm stdin: $bytesWritten bytes (expected: $fileSize)") } } } catch (e: Exception) { logE("Error piping APK to pm stdin", e) } } writeThread.start() // Read stdout/stderr for result val stdout = BufferedReader(InputStreamReader(process.inputStream)).readText().trim() val stderr = BufferedReader(InputStreamReader(process.errorStream)).readText().trim() writeThread.join(INSTALL_TIMEOUT_SECONDS * 1000) if (writeThread.isAlive) { logE("Write thread timed out after ${INSTALL_TIMEOUT_SECONDS}s, aborting install") writeThread.interrupt() process.destroyForcibly() return STATUS_FAILURE } val finished = process.waitFor(INSTALL_TIMEOUT_SECONDS, TimeUnit.SECONDS) if (!finished) { logE("pm install timed out after ${INSTALL_TIMEOUT_SECONDS}s, aborting") process.destroyForcibly() return STATUS_FAILURE } val exitCode = process.exitValue() log("pm install — exitCode=$exitCode, stdout='$stdout', stderr='$stderr'") if (exitCode == 0 && stdout.contains("Success")) { log("Install SUCCESS") STATUS_SUCCESS } else { logE("Install FAILED — exitCode=$exitCode, stdout='$stdout', stderr='$stderr'") STATUS_FAILURE } } catch (e: Exception) { logE("installPackage() exception", e) STATUS_FAILURE } } override fun uninstallPackage(packageName: String): Int { log("uninstallPackage() called for: $packageName") return try { val command = arrayOf("pm", "uninstall", packageName) log("Executing: ${command.joinToString(" ")}") val process = Runtime.getRuntime().exec(command) val stdout = BufferedReader(InputStreamReader(process.inputStream)).readText().trim() val stderr = BufferedReader(InputStreamReader(process.errorStream)).readText().trim() val finished = process.waitFor(UNINSTALL_TIMEOUT_SECONDS, TimeUnit.SECONDS) if (!finished) { logE("pm uninstall timed out after ${UNINSTALL_TIMEOUT_SECONDS}s, aborting") process.destroyForcibly() return STATUS_FAILURE } val exitCode = process.exitValue() log("pm uninstall — exitCode=$exitCode, stdout='$stdout', stderr='$stderr'") if (exitCode == 0 && stdout.contains("Success")) { log("Uninstall SUCCESS") STATUS_SUCCESS } else { logE("Uninstall FAILED — exitCode=$exitCode, stdout='$stdout', stderr='$stderr'") STATUS_FAILURE } } catch (e: Exception) { logE("uninstallPackage() exception", e) STATUS_FAILURE } } override fun destroy() { log("destroy() — service being unbound") } } ================================================ FILE: core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/shizuku/ShizukuInstallerWrapper.kt ================================================ package zed.rainxch.core.data.services.shizuku import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import zed.rainxch.core.data.services.shizuku.model.ShizukuStatus import zed.rainxch.core.domain.model.GithubAsset import zed.rainxch.core.domain.model.InstallerType import zed.rainxch.core.domain.model.SystemArchitecture import zed.rainxch.core.domain.repository.TweaksRepository import zed.rainxch.core.domain.system.Installer import zed.rainxch.core.domain.system.InstallerInfoExtractor /** * Wrapper around [Installer] that transparently intercepts `install()` and `uninstall()` * calls to use Shizuku when available and enabled by the user. * * All other methods (asset selection, architecture detection, Obtainium/AppManager support) * delegate directly to the underlying [androidInstaller] unchanged. * * Fallback behavior: if Shizuku install/uninstall fails for any reason, falls back * to the standard [androidInstaller] implementation silently. */ class ShizukuInstallerWrapper( private val androidInstaller: Installer, private val shizukuServiceManager: ShizukuServiceManager, private val tweaksRepository: TweaksRepository, ) : Installer { companion object { private const val TAG = "ShizukuInstaller" } /** * Cached installer type preference, updated by flow collection. * Defaults to DEFAULT so Shizuku is never used unless explicitly opted in. */ @Volatile private var cachedInstallerType: InstallerType = InstallerType.DEFAULT /** * Start observing the installer type preference from DataStore. * Call once after construction (from DI setup). */ fun observeInstallerPreference(scope: CoroutineScope) { scope.launch { tweaksRepository.getInstallerType().collect { type -> cachedInstallerType = type Logger.d(TAG) { "Installer type changed to: $type" } } } } override suspend fun isSupported(extOrMime: String): Boolean = androidInstaller.isSupported(extOrMime) override fun isAssetInstallable(assetName: String): Boolean = androidInstaller.isAssetInstallable(assetName) override fun choosePrimaryAsset(assets: List): GithubAsset? = androidInstaller.choosePrimaryAsset(assets) override fun detectSystemArchitecture(): SystemArchitecture = androidInstaller.detectSystemArchitecture() override fun isObtainiumInstalled(): Boolean = androidInstaller.isObtainiumInstalled() override fun openInObtainium( repoOwner: String, repoName: String, onOpenInstaller: () -> Unit, ) = androidInstaller.openInObtainium(repoOwner, repoName, onOpenInstaller) override fun isAppManagerInstalled(): Boolean = androidInstaller.isAppManagerInstalled() override fun openInAppManager( filePath: String, onOpenInstaller: () -> Unit, ) = androidInstaller.openInAppManager(filePath, onOpenInstaller) override fun getApkInfoExtractor(): InstallerInfoExtractor = androidInstaller.getApkInfoExtractor() override fun openApp(packageName: String): Boolean = androidInstaller.openApp(packageName) override fun openWithExternalInstaller(filePath: String) = androidInstaller.openWithExternalInstaller(filePath) override suspend fun ensurePermissionsOrThrow(extOrMime: String) { Logger.d(TAG) { "ensurePermissionsOrThrow() — extOrMime=$extOrMime, cachedType=$cachedInstallerType, status=${shizukuServiceManager.status.value}" } if (shouldUseShizuku()) { Logger.d(TAG) { "Shizuku active — skipping unknown sources permission check" } return } Logger.d(TAG) { "Delegating ensurePermissionsOrThrow to AndroidInstaller" } androidInstaller.ensurePermissionsOrThrow(extOrMime) } override suspend fun install( filePath: String, extOrMime: String, ) { Logger.d(TAG) { "install() called — filePath=$filePath, extOrMime=$extOrMime" } Logger.d(TAG) { "cachedInstallerType=$cachedInstallerType, shizukuStatus=${shizukuServiceManager.status.value}" } if (shouldUseShizuku()) { Logger.d(TAG) { "Shizuku is enabled and READY — attempting Shizuku install" } try { val service = shizukuServiceManager.getService() if (service != null) { val result = withContext(Dispatchers.IO) { val file = java.io.File(filePath) val pfd = android.os.ParcelFileDescriptor.open( file, android.os.ParcelFileDescriptor.MODE_READ_ONLY, ) pfd.use { Logger.d(TAG) { "Got Shizuku service, calling installPackage($filePath, size=${file.length()})..." } service.installPackage(it, file.length()) } } Logger.d(TAG) { "Shizuku installPackage() returned: $result" } if (result == 0) { Logger.d(TAG) { "Shizuku install SUCCEEDED for: $filePath" } return } Logger.w(TAG) { "Shizuku install FAILED with code: $result, falling back to standard installer" } } else { Logger.w(TAG) { "Shizuku service is NULL, falling back to standard installer" } } } catch (e: Exception) { Logger.e(TAG) { "Shizuku install exception, falling back: ${e.javaClass.simpleName}: ${e.message}" } } } else { Logger.d(TAG) { "Not using Shizuku (enabled=${isShizukuEnabled()}, status=${shizukuServiceManager.status.value})" } } Logger.d(TAG) { "Using standard AndroidInstaller for: $filePath" } androidInstaller.ensurePermissionsOrThrow(extOrMime) androidInstaller.install(filePath, extOrMime) } override fun uninstall(packageName: String) { Logger.d(TAG) { "uninstall() called — packageName=$packageName" } Logger.d(TAG) { "cachedInstallerType=$cachedInstallerType, shizukuStatus=${shizukuServiceManager.status.value}" } if (isShizukuEnabled() && shizukuServiceManager.status.value == ShizukuStatus.READY) { Logger.d(TAG) { "Attempting Shizuku uninstall..." } Thread { try { val service = runBlocking { shizukuServiceManager.getService() } if (service != null) { Logger.d(TAG) { "Got service, calling uninstallPackage($packageName)..." } val result = service.uninstallPackage(packageName) Logger.d(TAG) { "Shizuku uninstallPackage() returned: $result" } if (result == 0) { Logger.d(TAG) { "Shizuku uninstall SUCCEEDED for: $packageName" } } else { Logger.w(TAG) { "Shizuku uninstall FAILED with code: $result, falling back" } androidInstaller.uninstall(packageName) } } else { Logger.w(TAG) { "Shizuku service is NULL, falling back" } androidInstaller.uninstall(packageName) } } catch (e: Exception) { Logger.e(TAG) { "Shizuku uninstall exception, falling back: ${e.message}" } androidInstaller.uninstall(packageName) } }.start() return } Logger.d(TAG) { "Using standard AndroidInstaller uninstall for: $packageName" } androidInstaller.uninstall(packageName) } private suspend fun shouldUseShizuku(): Boolean = isShizukuEnabled() && shizukuServiceManager.status.value == ShizukuStatus.READY private fun isShizukuEnabled(): Boolean = cachedInstallerType == InstallerType.SHIZUKU } ================================================ FILE: core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/shizuku/ShizukuServiceManager.kt ================================================ package zed.rainxch.core.data.services.shizuku import android.content.ComponentName import android.content.Context import android.content.ServiceConnection import android.content.pm.PackageManager import android.os.IBinder import co.touchlab.kermit.Logger import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withTimeoutOrNull import rikka.shizuku.Shizuku import zed.rainxch.core.data.services.shizuku.model.ShizukuStatus import kotlin.coroutines.resume class ShizukuServiceManager( private val context: Context, ) { private val _status = MutableStateFlow(ShizukuStatus.NOT_INSTALLED) val status: StateFlow = _status.asStateFlow() private val bindMutex = Mutex() private var serviceConnection: ServiceConnection? = null private var boundUserServiceArgs: Shizuku.UserServiceArgs? = null @Volatile var installerService: IShizukuInstallerService? = null private set private val binderReceivedListener = Shizuku.OnBinderReceivedListener { Logger.d(TAG) { "Shizuku binder received" } refreshStatus() } private val binderDeadListener = Shizuku.OnBinderDeadListener { Logger.d(TAG) { "Shizuku binder dead" } installerService = null refreshStatus() } private val permissionResultListener = Shizuku.OnRequestPermissionResultListener { requestCode, grantResult -> Logger.d(TAG) { "Shizuku permission result: requestCode=$requestCode," + " granted=${grantResult == PackageManager.PERMISSION_GRANTED}" } refreshStatus() } fun initialize() { try { Shizuku.addBinderReceivedListenerSticky(binderReceivedListener) Shizuku.addBinderDeadListener(binderDeadListener) Shizuku.addRequestPermissionResultListener(permissionResultListener) } catch (e: Exception) { Logger.w(TAG) { "Failed to register Shizuku listeners: ${e.message}" } } refreshStatus() } fun refreshStatus() { _status.value = computeStatus() } private fun computeStatus(): ShizukuStatus { val installed = isShizukuInstalled() Logger.d(TAG) { "computeStatus() — shizukuInstalled=$installed" } if (!installed) return ShizukuStatus.NOT_INSTALLED return try { val binderAlive = Shizuku.pingBinder() Logger.d(TAG) { "computeStatus() — pingBinder=$binderAlive" } if (!binderAlive) return ShizukuStatus.NOT_RUNNING val permResult = Shizuku.checkSelfPermission() Logger.d(TAG) { "computeStatus() — checkSelfPermission=$permResult (GRANTED=${PackageManager.PERMISSION_GRANTED})" } if (permResult != PackageManager.PERMISSION_GRANTED) { return ShizukuStatus.PERMISSION_NEEDED } Logger.d(TAG) { "computeStatus() — READY" } ShizukuStatus.READY } catch (e: Exception) { Logger.w(TAG) { "Error checking Shizuku status: ${e.javaClass.simpleName}: ${e.message}" } ShizukuStatus.NOT_RUNNING } } private fun isShizukuInstalled(): Boolean = try { context.packageManager.getPackageInfo(SHIZUKU_PACKAGE, 0) true } catch (_: PackageManager.NameNotFoundException) { false } catch (_: Exception) { false } fun requestPermission() { try { if (Shizuku.pingBinder()) { Shizuku.requestPermission(SHIZUKU_PERMISSION_REQUEST_CODE) } } catch (e: Exception) { Logger.w(TAG) { "Failed to request Shizuku permission: ${e.message}" } } } suspend fun getService(): IShizukuInstallerService? { Logger.d(TAG) { "getService() — current status=${_status.value}" } if (_status.value != ShizukuStatus.READY) { Logger.w(TAG) { "getService() — Shizuku not READY (status=${_status.value}), returning null" } return null } return bindMutex.withLock { installerService?.let { service -> try { val alive = service.asBinder().pingBinder() Logger.d(TAG) { "getService() — cached service ping=$alive" } if (alive) return@withLock service Logger.w(TAG) { "getService() — cached service binder dead, rebinding..." } installerService = null } catch (e: Exception) { Logger.w(TAG) { "getService() — cached service error: ${e.message}, rebinding..." } installerService = null } } ?: run { Logger.d(TAG) { "getService() — no cached service, binding..." } } bindService() } } private suspend fun bindService(): IShizukuInstallerService? { Logger.d(TAG) { "bindService() — attempting to bind Shizuku UserService..." } return try { withTimeoutOrNull(BIND_TIMEOUT_MS) { suspendCancellableCoroutine { continuation -> val componentName = ComponentName( context.packageName, ShizukuInstallerServiceImpl::class.java.name, ) Logger.d(TAG) { "bindService() — component: $componentName" } val args = Shizuku .UserServiceArgs(componentName) .daemon(false) .processNameSuffix("installer") .version(1) val connection = object : ServiceConnection { override fun onServiceConnected( name: ComponentName?, binder: IBinder?, ) { Logger.d( TAG, ) { "onServiceConnected() — name=$name, binder=${binder?.javaClass?.name}, binderAlive=${binder?.pingBinder()}" } val service = IShizukuInstallerService.Stub.asInterface(binder) installerService = service Logger.d(TAG) { "Shizuku installer service connected and cached" } if (continuation.isActive) { continuation.resume(service) } } override fun onServiceDisconnected(name: ComponentName?) { installerService = null Logger.d(TAG) { "Shizuku installer service disconnected: $name" } } } serviceConnection = connection boundUserServiceArgs = args Logger.d(TAG) { "Calling Shizuku.bindUserService()..." } Shizuku.bindUserService(args, connection) Logger.d(TAG) { "Shizuku.bindUserService() called, waiting for callback..." } continuation.invokeOnCancellation { Logger.d(TAG) { "bindService() coroutine cancelled, unbinding..." } try { Shizuku.unbindUserService(args, connection, true) } catch (_: Exception) { } } } }.also { service -> if (service == null) { Logger.w(TAG) { "bindService() timed out after ${BIND_TIMEOUT_MS}ms" } } } } catch (e: Exception) { Logger.e(TAG) { "Failed to bind Shizuku service: ${e.javaClass.simpleName}: ${e.message}" } Logger.e(TAG) { e.stackTraceToString() } null } } companion object { private const val TAG = "ShizukuServiceManager" private const val SHIZUKU_PACKAGE = "moe.shizuku.privileged.api" private const val BIND_TIMEOUT_MS = 15_000L const val SHIZUKU_PERMISSION_REQUEST_CODE = 1001 } } ================================================ FILE: core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/shizuku/model/ShizukuStatus.kt ================================================ package zed.rainxch.core.data.services.shizuku.model enum class ShizukuStatus { NOT_INSTALLED, NOT_RUNNING, PERMISSION_NEEDED, READY, } ================================================ FILE: core/data/src/androidMain/kotlin/zed/rainxch/core/data/utils/AndroidAppLauncher.kt ================================================ package zed.rainxch.core.data.utils import android.content.Context import android.content.Intent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import zed.rainxch.core.domain.logging.GitHubStoreLogger import zed.rainxch.core.domain.model.InstalledApp import zed.rainxch.core.domain.utils.AppLauncher class AndroidAppLauncher( private val context: Context, private val logger: GitHubStoreLogger, ) : AppLauncher { override suspend fun launchApp(installedApp: InstalledApp): Result = withContext(Dispatchers.Main) { runCatching { val packageManager = context.packageManager val launchIntent = packageManager.getLaunchIntentForPackage(installedApp.packageName) if (launchIntent != null) { launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) context.startActivity(launchIntent) logger.debug("Launched app: ${installedApp.packageName}") } else { throw Exception("No launch intent found for ${installedApp.packageName}") } }.onFailure { error -> logger.error("Failed to launch app ${installedApp.packageName}: ${error.message}") } } override suspend fun canLaunchApp(installedApp: InstalledApp): Boolean = withContext(Dispatchers.IO) { try { val packageManager = context.packageManager packageManager.getLaunchIntentForPackage(installedApp.packageName) != null } catch (e: Exception) { false } } } ================================================ FILE: core/data/src/androidMain/kotlin/zed/rainxch/core/data/utils/AndroidBrowserHelper.kt ================================================ package zed.rainxch.core.data.utils import android.content.Context import android.content.Intent import androidx.core.net.toUri import zed.rainxch.core.domain.utils.BrowserHelper class AndroidBrowserHelper( private val context: Context, ) : BrowserHelper { override fun openUrl( url: String, onFailure: (error: String) -> Unit, ) { val intent = Intent(Intent.ACTION_VIEW, url.toUri()).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } context.startActivity(intent) } } ================================================ FILE: core/data/src/androidMain/kotlin/zed/rainxch/core/data/utils/AndroidClipboardHelper.kt ================================================ package zed.rainxch.core.data.utils import android.content.ClipData import android.content.ClipboardManager import android.content.Context import zed.rainxch.core.domain.utils.ClipboardHelper class AndroidClipboardHelper( private val context: Context, ) : ClipboardHelper { override fun copy( label: String, text: String, ) { val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager cm.setPrimaryClip(ClipData.newPlainText(label, text)) } override fun getText(): String? { val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val clip = cm.primaryClip ?: return null if (clip.itemCount == 0) return null return clip.getItemAt(0).text?.toString() } } ================================================ FILE: core/data/src/androidMain/kotlin/zed/rainxch/core/data/utils/AndroidShareManager.kt ================================================ package zed.rainxch.core.data.utils import android.app.Activity import android.content.Context import android.content.Intent import android.net.Uri import androidx.activity.ComponentActivity import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.core.content.FileProvider import zed.rainxch.core.domain.utils.ShareManager import java.io.File class AndroidShareManager( private val context: Context, ) : ShareManager { private var filePickerCallback: ((String?) -> Unit)? = null private var filePickerLauncher: ActivityResultLauncher? = null fun registerActivityResultLauncher(activity: ComponentActivity) { filePickerLauncher = activity.registerForActivityResult( ActivityResultContracts.StartActivityForResult() ) { result -> val callback = filePickerCallback filePickerCallback = null if (result.resultCode == Activity.RESULT_OK) { val uri = result.data?.data if (uri != null) { try { val content = context.contentResolver.openInputStream(uri) ?.bufferedReader() ?.use { it.readText() } callback?.invoke(content) } catch (e: Exception) { callback?.invoke(null) } } else { callback?.invoke(null) } } else { callback?.invoke(null) } } } override fun shareText(text: String) { val intent = Intent(Intent.ACTION_SEND).apply { type = "text/plain" putExtra(Intent.EXTRA_TEXT, text) } val chooser = Intent.createChooser(intent, null).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } context.startActivity(chooser) } override fun shareFile(fileName: String, content: String, mimeType: String) { val cacheDir = File(context.cacheDir, "exports") cacheDir.mkdirs() val file = File(cacheDir, fileName) file.writeText(content) val uri: Uri = FileProvider.getUriForFile( context, "${context.packageName}.fileprovider", file ) val intent = Intent(Intent.ACTION_SEND).apply { type = mimeType putExtra(Intent.EXTRA_STREAM, uri) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } val chooser = Intent.createChooser(intent, null).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } context.startActivity(chooser) } override fun pickFile(mimeType: String, onResult: (String?) -> Unit) { filePickerCallback = onResult val launcher = filePickerLauncher if (launcher != null) { val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { addCategory(Intent.CATEGORY_OPENABLE) type = mimeType } launcher.launch(intent) } else { // Fallback: try with ACTION_GET_CONTENT val intent = Intent(Intent.ACTION_GET_CONTENT).apply { addCategory(Intent.CATEGORY_OPENABLE) type = mimeType addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } try { context.startActivity(Intent.createChooser(intent, null).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }) // Note: fallback won't deliver result without launcher onResult(null) } catch (e: Exception) { onResult(null) } } } } ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/cache/CacheManager.kt ================================================ package zed.rainxch.core.data.cache import kotlinx.serialization.json.Json import kotlinx.serialization.serializer import zed.rainxch.core.data.local.db.dao.CacheDao import zed.rainxch.core.data.local.db.entities.CacheEntryEntity import kotlin.time.Clock import kotlin.time.Duration.Companion.hours class CacheManager( val cacheDao: CacheDao, ) { val json = Json { ignoreUnknownKeys = true isLenient = true encodeDefaults = true } val memoryCache = HashMap>() fun now(): Long = Clock.System.now().toEpochMilliseconds() suspend inline fun get(key: String): T? { val currentTime = now() memoryCache[key]?.let { (expiresAt, jsonData) -> if (expiresAt > currentTime) { return try { json.decodeFromString(serializer(), jsonData) } catch (_: Exception) { memoryCache.remove(key) null } } else { memoryCache.remove(key) } } val entry = cacheDao.getValid(key, currentTime) ?: return null memoryCache[key] = entry.expiresAt to entry.jsonData return try { json.decodeFromString(serializer(), entry.jsonData) } catch (_: Exception) { cacheDao.delete(key) memoryCache.remove(key) null } } suspend inline fun getStale(key: String): T? { val entry = cacheDao.getAny(key) ?: return null return try { json.decodeFromString(serializer(), entry.jsonData) } catch (_: Exception) { null } } suspend inline fun put( key: String, value: T, ttlMillis: Long, ) { val currentTime = now() val jsonData = json.encodeToString(serializer(), value) val expiresAt = currentTime + ttlMillis memoryCache[key] = expiresAt to jsonData cacheDao.put( CacheEntryEntity( key = key, jsonData = jsonData, cachedAt = currentTime, expiresAt = expiresAt, ), ) } suspend fun invalidate(key: String) { memoryCache.remove(key) cacheDao.delete(key) } suspend fun invalidateByPrefix(prefix: String) { val keysToRemove = memoryCache.keys.filter { it.startsWith(prefix) } keysToRemove.forEach { memoryCache.remove(it) } cacheDao.deleteByPrefix(prefix) } suspend fun clearAll() { memoryCache.clear() cacheDao.deleteAll() } suspend fun cleanupExpired() { val currentTime = now() val expiredKeys = memoryCache.entries .filter { it.value.first <= currentTime } .map { it.key } expiredKeys.forEach { memoryCache.remove(it) } cacheDao.deleteExpired(currentTime) } companion object CacheTtl { val HOME_REPOS = 12.hours.inWholeMilliseconds val REPO_DETAILS = 6.hours.inWholeMilliseconds val RELEASES = 6.hours.inWholeMilliseconds val README = 12.hours.inWholeMilliseconds val USER_PROFILE = 6.hours.inWholeMilliseconds val SEARCH_RESULTS = 1.hours.inWholeMilliseconds val REPO_STATS = 6.hours.inWholeMilliseconds } } ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/data_source/TokenStore.kt ================================================ package zed.rainxch.core.data.data_source import kotlinx.coroutines.flow.Flow import zed.rainxch.core.data.dto.GithubDeviceTokenSuccessDto interface TokenStore { fun tokenFlow(): Flow suspend fun currentToken(): GithubDeviceTokenSuccessDto? fun blockingCurrentToken(): GithubDeviceTokenSuccessDto? suspend fun save(token: GithubDeviceTokenSuccessDto) suspend fun clear() suspend fun isTokenExpired(): Boolean } ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/data_source/impl/DefaultTokenStore.kt ================================================ package zed.rainxch.core.data.data_source.impl import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json import zed.rainxch.core.data.data_source.TokenStore import zed.rainxch.core.data.dto.GithubDeviceTokenSuccessDto import kotlin.time.Clock class DefaultTokenStore( private val dataStore: DataStore, ) : TokenStore { private val TOKEN_KEY = stringPreferencesKey("token") private val json = Json { ignoreUnknownKeys = true } override suspend fun save(token: GithubDeviceTokenSuccessDto) { val stamped = token.copy( savedAtEpochMillis = token.savedAtEpochMillis ?: Clock.System.now().toEpochMilliseconds(), ) val jsonString = json.encodeToString(GithubDeviceTokenSuccessDto.serializer(), stamped) dataStore.edit { preferences -> preferences[TOKEN_KEY] = jsonString } } override fun tokenFlow(): Flow { return dataStore.data.map { preferences -> val raw = preferences[TOKEN_KEY] ?: return@map null runCatching { json.decodeFromString(GithubDeviceTokenSuccessDto.serializer(), raw) }.getOrNull() } } override suspend fun currentToken(): GithubDeviceTokenSuccessDto? { val preferences = dataStore.data.first() val raw = preferences[TOKEN_KEY] ?: return null return runCatching { json.decodeFromString(GithubDeviceTokenSuccessDto.serializer(), raw) }.getOrNull() } override fun blockingCurrentToken(): GithubDeviceTokenSuccessDto? = runBlocking { val preferences = dataStore.data.first() val raw = preferences[TOKEN_KEY] ?: return@runBlocking null runCatching { json.decodeFromString(GithubDeviceTokenSuccessDto.serializer(), raw) }.getOrNull() } override suspend fun clear() { dataStore.edit { it.remove(TOKEN_KEY) } } override suspend fun isTokenExpired(): Boolean { val token = currentToken() ?: return true val savedAt = token.savedAtEpochMillis ?: return false val expiresIn = token.expiresIn ?: return false val expiresAtMillis = savedAt + (expiresIn * 1000L) return Clock.System.now().toEpochMilliseconds() > expiresAtMillis } } ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/PlatformModule.kt ================================================ package zed.rainxch.core.data.di import org.koin.core.module.Module expect val corePlatformModule: Module ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt ================================================ package zed.rainxch.core.data.di import io.ktor.client.HttpClient import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout import org.koin.dsl.module import zed.rainxch.core.data.cache.CacheManager import zed.rainxch.core.data.data_source.TokenStore import zed.rainxch.core.data.data_source.impl.DefaultTokenStore import zed.rainxch.core.data.local.db.AppDatabase import zed.rainxch.core.data.local.db.dao.CacheDao import zed.rainxch.core.data.local.db.dao.FavoriteRepoDao import zed.rainxch.core.data.local.db.dao.InstalledAppDao import zed.rainxch.core.data.local.db.dao.SeenRepoDao import zed.rainxch.core.data.local.db.dao.StarredRepoDao import zed.rainxch.core.data.local.db.dao.UpdateHistoryDao import zed.rainxch.core.data.logging.KermitLogger import zed.rainxch.core.data.network.GitHubClientProvider import zed.rainxch.core.data.network.ProxyManager import zed.rainxch.core.data.network.createGitHubHttpClient import zed.rainxch.core.data.repository.AuthenticationStateImpl import zed.rainxch.core.data.repository.FavouritesRepositoryImpl import zed.rainxch.core.data.repository.InstalledAppsRepositoryImpl import zed.rainxch.core.data.repository.ProxyRepositoryImpl import zed.rainxch.core.data.repository.RateLimitRepositoryImpl import zed.rainxch.core.data.repository.SeenReposRepositoryImpl import zed.rainxch.core.data.repository.StarredRepositoryImpl import zed.rainxch.core.data.repository.TweaksRepositoryImpl import zed.rainxch.core.domain.getPlatform import zed.rainxch.core.domain.logging.GitHubStoreLogger import zed.rainxch.core.domain.model.Platform import zed.rainxch.core.domain.model.ProxyConfig import zed.rainxch.core.domain.repository.AuthenticationState import zed.rainxch.core.domain.repository.FavouritesRepository import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.repository.ProxyRepository import zed.rainxch.core.domain.repository.RateLimitRepository import zed.rainxch.core.domain.repository.SeenReposRepository import zed.rainxch.core.domain.repository.StarredRepository import zed.rainxch.core.domain.repository.TweaksRepository import zed.rainxch.core.domain.use_cases.SyncInstalledAppsUseCase val coreModule = module { single { CoroutineScope(Dispatchers.IO + SupervisorJob()) } single { KermitLogger } single { getPlatform() } single { AuthenticationStateImpl( tokenStore = get(), ) } single { FavouritesRepositoryImpl( favoriteRepoDao = get(), installedAppsDao = get(), ) } single { InstalledAppsRepositoryImpl( database = get(), installedAppsDao = get(), historyDao = get(), installer = get(), httpClient = get(), tweaksRepository = get(), ) } single { StarredRepositoryImpl( installedAppsDao = get(), starredRepoDao = get(), platform = get(), httpClient = get(), ) } single { TweaksRepositoryImpl( preferences = get(), ) } single { SeenReposRepositoryImpl( seenRepoDao = get(), ) } single { ProxyRepositoryImpl( preferences = get(), ) } single { SyncInstalledAppsUseCase( packageMonitor = get(), installedAppsRepository = get(), platform = get(), logger = get(), ) } single { CacheManager(cacheDao = get()) } } val networkModule = module { single { val config = runBlocking { runCatching { withTimeout(1_500L) { get().getProxyConfig().first() } }.getOrDefault(ProxyConfig.None) } when (config) { is ProxyConfig.None -> { ProxyManager.setNoProxy() } is ProxyConfig.System -> { ProxyManager.setSystemProxy() } is ProxyConfig.Http -> { ProxyManager.setHttpProxy( host = config.host, port = config.port, username = config.username, password = config.password, ) } is ProxyConfig.Socks -> { ProxyManager.setSocksProxy( host = config.host, port = config.port, username = config.username, password = config.password, ) } } GitHubClientProvider( tokenStore = get(), rateLimitRepository = get(), authenticationState = get(), proxyConfigFlow = ProxyManager.currentProxyConfig, ) } single { createGitHubHttpClient( tokenStore = get(), rateLimitRepository = get(), authenticationState = get(), scope = get(), ) } single { DefaultTokenStore( dataStore = get(), ) } single { RateLimitRepositoryImpl() } } val databaseModule = module { single { get().favoriteRepoDao } single { get().installedAppDao } single { get().starredReposDao } single { get().updateHistoryDao } single { get().cacheDao } single { get().seenRepoDao } } ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/AssetNetwork.kt ================================================ package zed.rainxch.core.data.dto import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class AssetNetwork( @SerialName("id") val id: Long, @SerialName("name") val name: String, @SerialName("content_type") val contentType: String, @SerialName("size") val size: Long, @SerialName("browser_download_url") val downloadUrl: String, @SerialName("uploader") val uploader: OwnerNetwork, ) ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/GitHubStarredResponse.kt ================================================ package zed.rainxch.core.data.dto import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class GitHubStarredResponse( val id: Long, val name: String, val owner: Owner, val description: String?, val language: String?, @SerialName("html_url") val htmlUrl: String, @SerialName("stargazers_count") val stargazersCount: Int, @SerialName("forks_count") val forksCount: Int, @SerialName("open_issues_count") val openIssuesCount: Int, @SerialName("starred_at") val starredAt: String? = null, ) { @Serializable data class Owner( val login: String, @SerialName("avatar_url") val avatarUrl: String, ) } ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/GithubDeviceStartDto.kt ================================================ package zed.rainxch.core.data.dto import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class GithubDeviceStartDto( @SerialName("device_code") val deviceCode: String, @SerialName("user_code") val userCode: String, @SerialName("verification_uri") val verificationUri: String, @SerialName("verification_uri_complete") val verificationUriComplete: String? = null, @SerialName("interval") val intervalSec: Int = 5, @SerialName("expires_in") val expiresInSec: Int, ) ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/GithubDeviceTokenErrorDto.kt ================================================ package zed.rainxch.core.data.dto import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class GithubDeviceTokenErrorDto( @SerialName("error") val error: String, @SerialName("error_description") val errorDescription: String? = null, ) ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/GithubDeviceTokenSuccessDto.kt ================================================ package zed.rainxch.core.data.dto import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class GithubDeviceTokenSuccessDto( @SerialName("access_token") val accessToken: String, @SerialName("token_type") val tokenType: String, @SerialName("expires_in") val expiresIn: Long? = null, @SerialName("scope") val scope: String? = null, @SerialName("refresh_token") val refreshToken: String? = null, @SerialName("refresh_token_expires_in") val refreshTokenExpiresIn: Long? = null, @SerialName("saved_at") val savedAtEpochMillis: Long? = null, ) ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/GithubOwnerNetworkModel.kt ================================================ package zed.rainxch.core.data.dto import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class GithubOwnerNetworkModel( @SerialName("id") val id: Long, @SerialName("login") val login: String, @SerialName("avatar_url") val avatarUrl: String, @SerialName("html_url") val htmlUrl: String, ) ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/GithubRepoNetworkModel.kt ================================================ package zed.rainxch.core.data.dto import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class GithubRepoNetworkModel( @SerialName("id") val id: Long, @SerialName("name") val name: String, @SerialName("full_name") val fullName: String, @SerialName("owner") val owner: GithubOwnerNetworkModel, @SerialName("description") val description: String? = null, @SerialName("default_branch") val defaultBranch: String, @SerialName("html_url") val htmlUrl: String, @SerialName("stargazers_count") val stargazersCount: Int, @SerialName("forks_count") val forksCount: Int, @SerialName("language") val language: String? = null, @SerialName("topics") val topics: List? = null, @SerialName("releases_url") val releasesUrl: String, @SerialName("updated_at") val updatedAt: String, @SerialName("fork") val fork: Boolean = false, ) ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/GithubRepoSearchResponse.kt ================================================ package zed.rainxch.core.data.dto import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class GithubRepoSearchResponse( @SerialName("total_count") val totalCount: Int, @SerialName("items") val items: List, ) ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/OwnerNetwork.kt ================================================ package zed.rainxch.core.data.dto import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class OwnerNetwork( @SerialName("id") val id: Long, @SerialName("login") val login: String, @SerialName("avatar_url") val avatarUrl: String, @SerialName("html_url") val htmlUrl: String, ) ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/ReleaseNetwork.kt ================================================ package zed.rainxch.core.data.dto import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class ReleaseNetwork( @SerialName("id") val id: Long, @SerialName("tag_name") val tagName: String, @SerialName("name") val name: String? = null, @SerialName("draft") val draft: Boolean? = null, @SerialName("prerelease") val prerelease: Boolean? = null, @SerialName("author") val author: OwnerNetwork, @SerialName("published_at") val publishedAt: String? = null, @SerialName("created_at") val createdAt: String? = null, @SerialName("body") val body: String? = null, @SerialName("tarball_url") val tarballUrl: String, @SerialName("zipball_url") val zipballUrl: String, @SerialName("html_url") val htmlUrl: String, @SerialName("assets") val assets: List, ) ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/RepoByIdNetwork.kt ================================================ package zed.rainxch.core.data.dto import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class RepoByIdNetwork( @SerialName("id") val id: Long, @SerialName("name") val name: String, @SerialName("full_name") val fullName: String, @SerialName("owner") val owner: OwnerNetwork, @SerialName("description") val description: String? = null, @SerialName("default_branch") val defaultBranch: String, @SerialName("html_url") val htmlUrl: String, @SerialName("stargazers_count") val stars: Int, @SerialName("forks_count") val forks: Int, @SerialName("language") val language: String? = null, @SerialName("topics") val topics: List? = null, @SerialName("updated_at") val updatedAt: String, ) ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/RepoInfoNetwork.kt ================================================ package zed.rainxch.core.data.dto import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class RepoInfoNetwork( @SerialName("stargazers_count") val stars: Int, @SerialName("forks_count") val forks: Int, @SerialName("open_issues_count") val openIssues: Int, ) ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/UserProfileNetwork.kt ================================================ package zed.rainxch.core.data.dto import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class UserProfileNetwork( @SerialName("id") val id: Long, @SerialName("login") val login: String, @SerialName("name") val name: String? = null, @SerialName("bio") val bio: String? = null, @SerialName("avatar_url") val avatarUrl: String, @SerialName("html_url") val htmlUrl: String, @SerialName("followers") val followers: Int, @SerialName("following") val following: Int, @SerialName("public_repos") val publicRepos: Int, @SerialName("location") val location: String? = null, @SerialName("company") val company: String? = null, @SerialName("blog") val blog: String? = null, @SerialName("twitter_username") val twitterUsername: String? = null, ) ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/data_store/createDataStoreCore.kt ================================================ package zed.rainxch.core.data.local.data_store import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences import okio.Path.Companion.toPath fun createDataStore(producePath: () -> String): DataStore = PreferenceDataStoreFactory.createWithPath( produceFile = { producePath().toPath() }, ) internal const val dataStoreFileName = "github_store.preferences_pb" ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/AppDatabase.kt ================================================ package zed.rainxch.core.data.local.db import androidx.room.Database import androidx.room.RoomDatabase import zed.rainxch.core.data.local.db.dao.CacheDao import zed.rainxch.core.data.local.db.dao.FavoriteRepoDao import zed.rainxch.core.data.local.db.dao.InstalledAppDao import zed.rainxch.core.data.local.db.dao.SeenRepoDao import zed.rainxch.core.data.local.db.dao.StarredRepoDao import zed.rainxch.core.data.local.db.dao.UpdateHistoryDao import zed.rainxch.core.data.local.db.entities.CacheEntryEntity import zed.rainxch.core.data.local.db.entities.FavoriteRepoEntity import zed.rainxch.core.data.local.db.entities.InstalledAppEntity import zed.rainxch.core.data.local.db.entities.SeenRepoEntity import zed.rainxch.core.data.local.db.entities.StarredRepositoryEntity import zed.rainxch.core.data.local.db.entities.UpdateHistoryEntity @Database( entities = [ InstalledAppEntity::class, FavoriteRepoEntity::class, UpdateHistoryEntity::class, StarredRepositoryEntity::class, CacheEntryEntity::class, SeenRepoEntity::class, ], version = 6, exportSchema = true, ) abstract class AppDatabase : RoomDatabase() { abstract val installedAppDao: InstalledAppDao abstract val favoriteRepoDao: FavoriteRepoDao abstract val updateHistoryDao: UpdateHistoryDao abstract val starredReposDao: StarredRepoDao abstract val cacheDao: CacheDao abstract val seenRepoDao: SeenRepoDao } ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/CacheDao.kt ================================================ package zed.rainxch.core.data.local.db.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import zed.rainxch.core.data.local.db.entities.CacheEntryEntity @Dao interface CacheDao { @Query("SELECT * FROM cache_entries WHERE `key` = :key AND expiresAt > :now LIMIT 1") suspend fun getValid( key: String, now: Long, ): CacheEntryEntity? @Query("SELECT * FROM cache_entries WHERE `key` = :key LIMIT 1") suspend fun getAny(key: String): CacheEntryEntity? @Query("SELECT * FROM cache_entries WHERE `key` LIKE :prefix || '%' AND expiresAt > :now") suspend fun getValidByPrefix( prefix: String, now: Long, ): List @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun put(entry: CacheEntryEntity) @Query("DELETE FROM cache_entries WHERE `key` = :key") suspend fun delete(key: String) @Query("DELETE FROM cache_entries WHERE `key` LIKE :prefix || '%'") suspend fun deleteByPrefix(prefix: String) @Query("DELETE FROM cache_entries WHERE expiresAt <= :now") suspend fun deleteExpired(now: Long) @Query("DELETE FROM cache_entries") suspend fun deleteAll() } ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/FavoriteRepoDao.kt ================================================ package zed.rainxch.core.data.local.db.dao import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import kotlinx.coroutines.flow.Flow import zed.rainxch.core.data.local.db.entities.FavoriteRepoEntity @Dao interface FavoriteRepoDao { @Query("SELECT * FROM favorite_repos ORDER BY addedAt DESC") fun getAllFavorites(): Flow> @Query("SELECT * FROM favorite_repos WHERE repoId = :repoId") suspend fun getFavoriteById(repoId: Long): FavoriteRepoEntity? @Query("SELECT EXISTS(SELECT 1 FROM favorite_repos WHERE repoId = :repoId)") fun isFavorite(repoId: Long): Flow @Query("SELECT EXISTS(SELECT 1 FROM favorite_repos WHERE repoId = :repoId)") suspend fun isFavoriteSync(repoId: Long): Boolean @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertFavorite(repo: FavoriteRepoEntity) @Delete suspend fun deleteFavorite(repo: FavoriteRepoEntity) @Query("DELETE FROM favorite_repos WHERE repoId = :repoId") suspend fun deleteFavoriteById(repoId: Long) @Query( """ UPDATE favorite_repos SET isInstalled = :installed, installedPackageName = :packageName WHERE repoId = :repoId """, ) suspend fun updateInstallStatus( repoId: Long, installed: Boolean, packageName: String?, ) @Query( """ UPDATE favorite_repos SET latestVersion = :version, latestReleaseUrl = :releaseUrl, lastSyncedAt = :timestamp WHERE repoId = :repoId """, ) suspend fun updateLatestVersion( repoId: Long, version: String?, releaseUrl: String?, timestamp: Long, ) } ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/InstalledAppDao.kt ================================================ package zed.rainxch.core.data.local.db.dao import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Update import kotlinx.coroutines.flow.Flow import zed.rainxch.core.data.local.db.entities.InstalledAppEntity @Dao interface InstalledAppDao { @Query("SELECT * FROM installed_apps ORDER BY installedAt DESC") fun getAllInstalledApps(): Flow> @Query("SELECT * FROM installed_apps WHERE isUpdateAvailable = 1 ORDER BY lastCheckedAt DESC") fun getAppsWithUpdates(): Flow> @Query("SELECT * FROM installed_apps WHERE packageName = :packageName") suspend fun getAppByPackage(packageName: String): InstalledAppEntity? @Query("SELECT * FROM installed_apps WHERE repoId = :repoId") suspend fun getAppByRepoId(repoId: Long): InstalledAppEntity? @Query("SELECT * FROM installed_apps WHERE repoId = :repoId") fun getAppByRepoIdAsFlow(repoId: Long): Flow @Query("SELECT COUNT(*) FROM installed_apps WHERE isUpdateAvailable = 1") fun getUpdateCount(): Flow @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertApp(app: InstalledAppEntity) @Update suspend fun updateApp(app: InstalledAppEntity) @Delete suspend fun deleteApp(app: InstalledAppEntity) @Query("DELETE FROM installed_apps WHERE packageName = :packageName") suspend fun deleteByPackageName(packageName: String) @Query( """ UPDATE installed_apps SET isUpdateAvailable = :available, latestVersion = :version, latestAssetName = :assetName, latestAssetUrl = :assetUrl, latestAssetSize = :assetSize, releaseNotes = :releaseNotes, lastCheckedAt = :timestamp, latestVersionName = :latestVersionName, latestVersionCode = :latestVersionCode WHERE packageName = :packageName """, ) suspend fun updateVersionInfo( packageName: String, available: Boolean, version: String?, assetName: String?, assetUrl: String?, assetSize: Long?, releaseNotes: String?, timestamp: Long, latestVersionName: String?, latestVersionCode: Long?, ) @Query("UPDATE installed_apps SET lastCheckedAt = :timestamp WHERE packageName = :packageName") suspend fun updateLastChecked( packageName: String, timestamp: Long, ) } ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/SeenRepoDao.kt ================================================ package zed.rainxch.core.data.local.db.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import kotlinx.coroutines.flow.Flow import zed.rainxch.core.data.local.db.entities.SeenRepoEntity @Dao interface SeenRepoDao { @Query("SELECT repoId FROM seen_repos") fun getAllSeenRepoIds(): Flow> @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insert(entity: SeenRepoEntity) @Query("DELETE FROM seen_repos") suspend fun clearAll() } ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/StarredRepoDao.kt ================================================ package zed.rainxch.core.data.local.db.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction import kotlinx.coroutines.flow.Flow import zed.rainxch.core.data.local.db.entities.StarredRepositoryEntity @Dao interface StarredRepoDao { @Query("SELECT * FROM starred_repos ORDER BY starredAt DESC") fun getAllStarred(): Flow> @Query("SELECT * FROM starred_repos WHERE repoId = :repoId") suspend fun getStarredById(repoId: Long): StarredRepositoryEntity? @Query("SELECT EXISTS(SELECT 1 FROM starred_repos WHERE repoId = :repoId)") suspend fun isStarred(repoId: Long): Boolean @Query("SELECT EXISTS(SELECT 1 FROM starred_repos WHERE repoId = :repoId)") fun isStarredFlow(repoId: Long): Flow @Query("SELECT EXISTS(SELECT 1 FROM starred_repos WHERE repoId = :repoId)") suspend fun isStarredSync(repoId: Long): Boolean @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertStarred(repo: StarredRepositoryEntity) @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertAllStarred(repos: List) @Query("DELETE FROM starred_repos WHERE repoId = :repoId") suspend fun deleteStarredById(repoId: Long) @Query("DELETE FROM starred_repos") suspend fun clearAll() @Query( """ UPDATE starred_repos SET isInstalled = :installed, installedPackageName = :packageName WHERE repoId = :repoId """, ) suspend fun updateInstallStatus( repoId: Long, installed: Boolean, packageName: String?, ) @Query( """ UPDATE starred_repos SET latestVersion = :version, latestReleaseUrl = :releaseUrl, lastSyncedAt = :timestamp WHERE repoId = :repoId """, ) suspend fun updateLatestVersion( repoId: Long, version: String?, releaseUrl: String?, timestamp: Long, ) @Query("SELECT COUNT(*) FROM starred_repos") suspend fun getCount(): Int @Query("SELECT MAX(lastSyncedAt) FROM starred_repos") suspend fun getLastSyncTime(): Long? @Transaction suspend fun replaceAllStarred(repos: List) { clearAll() insertAllStarred(repos) } } ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/UpdateHistoryDao.kt ================================================ package zed.rainxch.core.data.local.db.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.Query import kotlinx.coroutines.flow.Flow import zed.rainxch.core.data.local.db.entities.UpdateHistoryEntity @Dao interface UpdateHistoryDao { @Query("SELECT * FROM update_history ORDER BY updatedAt DESC LIMIT 50") fun getRecentHistory(): Flow> @Query("SELECT * FROM update_history WHERE packageName = :packageName ORDER BY updatedAt DESC") fun getHistoryForApp(packageName: String): Flow> @Insert suspend fun insertHistory(history: UpdateHistoryEntity) @Query("DELETE FROM update_history WHERE updatedAt < :timestamp") suspend fun deleteOldHistory(timestamp: Long) } ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/entities/CacheEntryEntity.kt ================================================ package zed.rainxch.core.data.local.db.entities import androidx.room.Entity import androidx.room.PrimaryKey @Entity(tableName = "cache_entries") data class CacheEntryEntity( @PrimaryKey val key: String, val jsonData: String, val cachedAt: Long, val expiresAt: Long, ) ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/entities/FavoriteRepoEntity.kt ================================================ package zed.rainxch.core.data.local.db.entities import androidx.room.Entity import androidx.room.PrimaryKey @Entity(tableName = "favorite_repos") data class FavoriteRepoEntity( @PrimaryKey val repoId: Long, val repoName: String, val repoOwner: String, val repoOwnerAvatarUrl: String, val repoDescription: String?, val primaryLanguage: String?, val repoUrl: String, val isInstalled: Boolean = false, val installedPackageName: String? = null, val latestVersion: String?, val latestReleaseUrl: String?, val addedAt: Long, val lastSyncedAt: Long, ) ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/entities/InstalledAppEntity.kt ================================================ package zed.rainxch.core.data.local.db.entities import androidx.room.Entity import androidx.room.PrimaryKey import zed.rainxch.core.domain.model.InstallSource @Entity(tableName = "installed_apps") data class InstalledAppEntity( @PrimaryKey val packageName: String, val repoId: Long, val repoName: String, val repoOwner: String, val repoOwnerAvatarUrl: String, val repoDescription: String?, val primaryLanguage: String?, val repoUrl: String, val installedVersion: String, val installedAssetName: String?, val installedAssetUrl: String?, val latestVersion: String?, val latestAssetName: String?, val latestAssetUrl: String?, val latestAssetSize: Long?, val appName: String, val installSource: InstallSource, val signingFingerprint: String?, val installedAt: Long, val lastCheckedAt: Long, val lastUpdatedAt: Long, val isUpdateAvailable: Boolean, val updateCheckEnabled: Boolean = true, val releaseNotes: String? = "", val systemArchitecture: String, val fileExtension: String, val isPendingInstall: Boolean = false, val installedVersionName: String? = null, val installedVersionCode: Long = 0L, val latestVersionName: String? = null, val latestVersionCode: Long? = null, ) ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/entities/SeenRepoEntity.kt ================================================ package zed.rainxch.core.data.local.db.entities import androidx.room.Entity import androidx.room.PrimaryKey @Entity(tableName = "seen_repos") data class SeenRepoEntity( @PrimaryKey val repoId: Long, val seenAt: Long, ) ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/entities/StarredRepositoryEntity.kt ================================================ package zed.rainxch.core.data.local.db.entities import androidx.room.Entity import androidx.room.PrimaryKey @Entity(tableName = "starred_repos") data class StarredRepositoryEntity( @PrimaryKey val repoId: Long, val repoName: String, val repoOwner: String, val repoOwnerAvatarUrl: String, val repoDescription: String?, val primaryLanguage: String?, val repoUrl: String, val stargazersCount: Int, val forksCount: Int, val openIssuesCount: Int, val isInstalled: Boolean = false, val installedPackageName: String? = null, val latestVersion: String?, val latestReleaseUrl: String?, val starredAt: Long?, val addedAt: Long, val lastSyncedAt: Long, ) ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/entities/UpdateHistoryEntity.kt ================================================ package zed.rainxch.core.data.local.db.entities import androidx.room.Entity import androidx.room.PrimaryKey import zed.rainxch.core.domain.model.InstallSource @Entity(tableName = "update_history") data class UpdateHistoryEntity( @PrimaryKey(autoGenerate = true) val id: Long = 0, val packageName: String, val appName: String, val repoOwner: String, val repoName: String, val fromVersion: String, val toVersion: String, val updatedAt: Long, val updateSource: InstallSource, val success: Boolean = true, val errorMessage: String? = null, ) ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/logging/KermitLogger.kt ================================================ package zed.rainxch.core.data.logging import co.touchlab.kermit.Logger import zed.rainxch.core.domain.logging.GitHubStoreLogger object KermitLogger : GitHubStoreLogger { override fun debug(message: String) { Logger.d(message) } override fun info(message: String) { Logger.i(message) } override fun warn(message: String) { Logger.w(message) } override fun error( message: String, throwable: Throwable?, ) { Logger.e(message, throwable) } } ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/mappers/AssetNetwork.kt ================================================ package zed.rainxch.core.data.mappers import zed.rainxch.core.data.dto.AssetNetwork import zed.rainxch.core.domain.model.GithubAsset import zed.rainxch.core.domain.model.GithubUser fun AssetNetwork.toDomain(): GithubAsset = GithubAsset( id = id, name = name, contentType = contentType, size = size, downloadUrl = downloadUrl, uploader = GithubUser( id = uploader.id, login = uploader.login, avatarUrl = uploader.avatarUrl, htmlUrl = uploader.htmlUrl, ), ) ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/mappers/FavouriteRepoMappers.kt ================================================ package zed.rainxch.core.data.mappers import zed.rainxch.core.data.local.db.entities.FavoriteRepoEntity import zed.rainxch.core.domain.model.FavoriteRepo fun FavoriteRepo.toEntity(): FavoriteRepoEntity = FavoriteRepoEntity( repoId = repoId, repoName = repoName, repoOwner = repoOwner, repoOwnerAvatarUrl = repoOwnerAvatarUrl, repoDescription = repoDescription, primaryLanguage = primaryLanguage, repoUrl = repoUrl, isInstalled = isInstalled, installedPackageName = installedPackageName, latestVersion = latestVersion, latestReleaseUrl = latestReleaseUrl, addedAt = addedAt, lastSyncedAt = lastSyncedAt, ) fun FavoriteRepoEntity.toDomain(): FavoriteRepo = FavoriteRepo( repoId = repoId, repoName = repoName, repoOwner = repoOwner, repoOwnerAvatarUrl = repoOwnerAvatarUrl, repoDescription = repoDescription, primaryLanguage = primaryLanguage, repoUrl = repoUrl, isInstalled = isInstalled, installedPackageName = installedPackageName, latestVersion = latestVersion, latestReleaseUrl = latestReleaseUrl, addedAt = addedAt, lastSyncedAt = lastSyncedAt, ) ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/mappers/GithubAuthMappers.kt ================================================ package zed.rainxch.core.data.mappers import zed.rainxch.core.data.dto.GithubDeviceStartDto import zed.rainxch.core.data.dto.GithubDeviceTokenErrorDto import zed.rainxch.core.data.dto.GithubDeviceTokenSuccessDto import zed.rainxch.core.domain.model.GithubDeviceStart import zed.rainxch.core.domain.model.GithubDeviceTokenError import zed.rainxch.core.domain.model.GithubDeviceTokenSuccess fun GithubDeviceStartDto.toDomain() = GithubDeviceStart( deviceCode = deviceCode, userCode = userCode, verificationUri = verificationUri, verificationUriComplete = verificationUriComplete, intervalSec = intervalSec, expiresInSec = expiresInSec, ) fun GithubDeviceTokenSuccessDto.toDomain() = GithubDeviceTokenSuccess( accessToken = accessToken, tokenType = tokenType, expiresIn = expiresIn, scope = scope, refreshToken = refreshToken, refreshTokenExpiresIn = refreshTokenExpiresIn, ) fun GithubDeviceTokenErrorDto.toDomain() = GithubDeviceTokenError( error = error, errorDescription = errorDescription, ) fun GithubDeviceStart.toData() = GithubDeviceStartDto( deviceCode = deviceCode, userCode = userCode, verificationUri = verificationUri, verificationUriComplete = verificationUriComplete, intervalSec = intervalSec, expiresInSec = expiresInSec, ) fun GithubDeviceTokenSuccess.toData() = GithubDeviceTokenSuccessDto( accessToken = accessToken, tokenType = tokenType, expiresIn = expiresIn, scope = scope, refreshToken = refreshToken, refreshTokenExpiresIn = refreshTokenExpiresIn, ) fun GithubDeviceTokenError.toData() = GithubDeviceTokenErrorDto( error = error, errorDescription = errorDescription, ) ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/mappers/GithubRepoMapper.kt ================================================ package zed.rainxch.core.data.mappers import zed.rainxch.core.data.dto.GithubRepoNetworkModel import zed.rainxch.core.domain.model.GithubRepoSummary import zed.rainxch.core.domain.model.GithubUser fun GithubRepoNetworkModel.toSummary(): GithubRepoSummary = GithubRepoSummary( id = id, name = name, fullName = fullName, owner = GithubUser( id = owner.id, login = owner.login, avatarUrl = owner.avatarUrl, htmlUrl = owner.htmlUrl, ), description = description, htmlUrl = htmlUrl, stargazersCount = stargazersCount, forksCount = forksCount, language = language, topics = topics, releasesUrl = releasesUrl, updatedAt = updatedAt, defaultBranch = defaultBranch, isFork = fork, ) ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/mappers/InstalledAppsMappers.kt ================================================ package zed.rainxch.core.data.mappers import zed.rainxch.core.data.local.db.entities.InstalledAppEntity import zed.rainxch.core.domain.model.InstalledApp fun InstalledApp.toEntity(): InstalledAppEntity = InstalledAppEntity( packageName = packageName, repoId = repoId, repoName = repoName, repoOwner = repoOwner, repoOwnerAvatarUrl = repoOwnerAvatarUrl, repoDescription = repoDescription, primaryLanguage = primaryLanguage, repoUrl = repoUrl, installedVersion = installedVersion, installedAssetName = installedAssetName, installedAssetUrl = installedAssetUrl, latestVersion = latestVersion, latestAssetName = latestAssetName, latestAssetUrl = latestAssetUrl, latestAssetSize = latestAssetSize, appName = appName, installSource = installSource, installedAt = installedAt, lastCheckedAt = lastCheckedAt, lastUpdatedAt = lastUpdatedAt, isUpdateAvailable = isUpdateAvailable, updateCheckEnabled = updateCheckEnabled, releaseNotes = releaseNotes, systemArchitecture = systemArchitecture, fileExtension = fileExtension, isPendingInstall = isPendingInstall, installedVersionName = installedVersionName, installedVersionCode = installedVersionCode, latestVersionName = latestVersionName, latestVersionCode = latestVersionCode, signingFingerprint = signingFingerprint, ) fun InstalledAppEntity.toDomain(): InstalledApp = InstalledApp( packageName = packageName, repoId = repoId, repoName = repoName, repoOwner = repoOwner, repoOwnerAvatarUrl = repoOwnerAvatarUrl, repoDescription = repoDescription, primaryLanguage = primaryLanguage, repoUrl = repoUrl, installedVersion = installedVersion, installedAssetName = installedAssetName, installedAssetUrl = installedAssetUrl, latestVersion = latestVersion, latestAssetName = latestAssetName, latestAssetUrl = latestAssetUrl, latestAssetSize = latestAssetSize, appName = appName, installSource = installSource, installedAt = installedAt, lastCheckedAt = lastCheckedAt, lastUpdatedAt = lastUpdatedAt, isUpdateAvailable = isUpdateAvailable, updateCheckEnabled = updateCheckEnabled, releaseNotes = releaseNotes, systemArchitecture = systemArchitecture, fileExtension = fileExtension, isPendingInstall = isPendingInstall, installedVersionName = installedVersionName, installedVersionCode = installedVersionCode, latestVersionName = latestVersionName, latestVersionCode = latestVersionCode, signingFingerprint = signingFingerprint, ) ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/mappers/ReleaseNetwork.kt ================================================ package zed.rainxch.core.data.mappers import zed.rainxch.core.data.dto.ReleaseNetwork import zed.rainxch.core.domain.model.GithubRelease import zed.rainxch.core.domain.model.GithubUser fun ReleaseNetwork.toDomain(): GithubRelease = GithubRelease( id = id, tagName = tagName, name = name, author = GithubUser( id = author.id, login = author.login, avatarUrl = author.avatarUrl, htmlUrl = author.htmlUrl, ), publishedAt = publishedAt ?: createdAt ?: "", description = body, assets = assets.map { it.toDomain() }, tarballUrl = tarballUrl, zipballUrl = zipballUrl, htmlUrl = htmlUrl, isPrerelease = prerelease == true, ) ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/mappers/StarredRepoMapper.kt ================================================ package zed.rainxch.core.data.mappers import zed.rainxch.core.data.local.db.entities.StarredRepositoryEntity import zed.rainxch.core.domain.model.StarredRepository fun StarredRepository.toEntity(): StarredRepositoryEntity = StarredRepositoryEntity( repoId = repoId, repoName = repoName, repoOwner = repoOwner, repoOwnerAvatarUrl = repoOwnerAvatarUrl, repoDescription = repoDescription, primaryLanguage = primaryLanguage, repoUrl = repoUrl, stargazersCount = stargazersCount, forksCount = forksCount, openIssuesCount = openIssuesCount, isInstalled = isInstalled, installedPackageName = installedPackageName, latestVersion = latestVersion, latestReleaseUrl = latestReleaseUrl, starredAt = starredAt, addedAt = addedAt, lastSyncedAt = lastSyncedAt, ) fun StarredRepositoryEntity.toDomain(): StarredRepository = StarredRepository( repoId = repoId, repoName = repoName, repoOwner = repoOwner, repoOwnerAvatarUrl = repoOwnerAvatarUrl, repoDescription = repoDescription, primaryLanguage = primaryLanguage, repoUrl = repoUrl, stargazersCount = stargazersCount, forksCount = forksCount, openIssuesCount = openIssuesCount, isInstalled = isInstalled, installedPackageName = installedPackageName, latestVersion = latestVersion, latestReleaseUrl = latestReleaseUrl, starredAt = starredAt, addedAt = addedAt, lastSyncedAt = lastSyncedAt, ) ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/mappers/UpdateHistoryMapper.kt ================================================ package zed.rainxch.core.data.mappers import zed.rainxch.core.data.local.db.entities.UpdateHistoryEntity import zed.rainxch.core.domain.model.UpdateHistory fun UpdateHistory.toEntity(): UpdateHistoryEntity = UpdateHistoryEntity( id = id, packageName = packageName, appName = appName, repoOwner = repoOwner, repoName = repoName, fromVersion = fromVersion, toVersion = toVersion, updatedAt = updatedAt, updateSource = updateSource, success = success, errorMessage = errorMessage, ) fun UpdateHistoryEntity.toDomain(): UpdateHistory = UpdateHistory( id = id, packageName = packageName, appName = appName, repoOwner = repoOwner, repoName = repoName, fromVersion = fromVersion, toVersion = toVersion, updatedAt = updatedAt, updateSource = updateSource, success = success, errorMessage = errorMessage, ) ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/GitHubClientProvider.kt ================================================ package zed.rainxch.core.data.network import io.ktor.client.HttpClient import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import zed.rainxch.core.data.data_source.TokenStore import zed.rainxch.core.domain.model.ProxyConfig import zed.rainxch.core.domain.repository.AuthenticationState import zed.rainxch.core.domain.repository.RateLimitRepository class GitHubClientProvider( private val tokenStore: TokenStore, private val rateLimitRepository: RateLimitRepository, private val authenticationState: AuthenticationState, proxyConfigFlow: StateFlow, ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val mutex = Mutex() @Volatile private var currentClient: HttpClient = createGitHubHttpClient( tokenStore = tokenStore, rateLimitRepository = rateLimitRepository, authenticationState = authenticationState, scope = scope, proxyConfig = proxyConfigFlow.value, ) init { proxyConfigFlow .drop(1) .distinctUntilChanged() .onEach { proxyConfig -> mutex.withLock { currentClient.close() currentClient = createGitHubHttpClient( tokenStore = tokenStore, rateLimitRepository = rateLimitRepository, authenticationState = authenticationState, scope = scope, proxyConfig = proxyConfig, ) } }.launchIn(scope) } val client: HttpClient get() = currentClient fun close() { currentClient.close() scope.cancel() } } ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.kt ================================================ package zed.rainxch.core.data.network import io.ktor.client.* import io.ktor.client.call.body import io.ktor.client.plugins.* import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.request.header import io.ktor.client.statement.HttpResponse import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* import io.ktor.util.network.UnresolvedAddressException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.serialization.json.Json import zed.rainxch.core.data.data_source.TokenStore import zed.rainxch.core.data.network.interceptor.RateLimitInterceptor import zed.rainxch.core.data.network.interceptor.UnauthorizedInterceptor import zed.rainxch.core.domain.model.ProxyConfig import zed.rainxch.core.domain.model.RateLimitException import zed.rainxch.core.domain.repository.AuthenticationState import zed.rainxch.core.domain.repository.RateLimitRepository import java.io.IOException import kotlin.coroutines.cancellation.CancellationException expect fun createPlatformHttpClient(proxyConfig: ProxyConfig): HttpClient fun createGitHubHttpClient( tokenStore: TokenStore, rateLimitRepository: RateLimitRepository, authenticationState: AuthenticationState? = null, scope: CoroutineScope? = null, proxyConfig: ProxyConfig = ProxyConfig.None, ): HttpClient { val json = Json { ignoreUnknownKeys = true isLenient = true } return createPlatformHttpClient(proxyConfig).config { install(RateLimitInterceptor) { this.rateLimitRepository = rateLimitRepository } if (authenticationState != null && scope != null) { install(UnauthorizedInterceptor) { this.authenticationState = authenticationState this.scope = scope } } install(ContentNegotiation) { json(json) } install(HttpTimeout) { requestTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS connectTimeoutMillis = 30_000 socketTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS } install(HttpRequestRetry) { maxRetries = 3 retryIf { _, response -> val code = response.status.value if (code == 403) { val remaining = response.headers["X-RateLimit-Remaining"]?.toIntOrNull() if (remaining == 0) return@retryIf false } code in 500..<600 } retryOnExceptionIf { _, cause -> cause is HttpRequestTimeoutException || cause is UnresolvedAddressException || cause is IOException } exponentialDelay() } expectSuccess = false defaultRequest { url("https://api.github.com") header(HttpHeaders.Accept, "application/vnd.github+json") header("X-GitHub-Api-Version", "2022-11-28") header(HttpHeaders.UserAgent, "GithubStore/1.0 (KMP)") val token = tokenStore .blockingCurrentToken() ?.accessToken ?.trim() .orEmpty() if (token.isNotEmpty()) { header(HttpHeaders.Authorization, "Bearer $token") } } } } suspend inline fun HttpClient.executeRequest(crossinline block: suspend HttpClient.() -> HttpResponse): Result = try { val response = block() if (response.status.isSuccess()) { Result.success(response.body()) } else { Result.failure( Exception("HTTP ${response.status.value}: ${response.status.description}"), ) } } catch (e: RateLimitException) { throw e } catch (e: CancellationException) { throw e } catch (e: Exception) { Result.failure(e) } ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ProxyManager.kt ================================================ package zed.rainxch.core.data.network import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import zed.rainxch.core.domain.model.ProxyConfig object ProxyManager { private val _proxyConfig = MutableStateFlow(ProxyConfig.None) val currentProxyConfig: StateFlow = _proxyConfig.asStateFlow() fun setNoProxy() { _proxyConfig.value = ProxyConfig.None } fun setSystemProxy() { _proxyConfig.value = ProxyConfig.System } fun setHttpProxy( host: String, port: Int, username: String? = null, password: String? = null, ) { _proxyConfig.value = ProxyConfig.Http(host, port, username, password) } fun setSocksProxy( host: String, port: Int, username: String? = null, password: String? = null, ) { _proxyConfig.value = ProxyConfig.Socks(host, port, username, password) } } ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/interceptor/RateLimitInterceptor.kt ================================================ package zed.rainxch.core.data.network.interceptor import co.touchlab.kermit.Logger import io.ktor.client.HttpClient import io.ktor.client.plugins.HttpClientPlugin import io.ktor.client.statement.HttpReceivePipeline import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.HttpResponsePipeline import io.ktor.http.Headers import io.ktor.util.AttributeKey import zed.rainxch.core.domain.logging.GitHubStoreLogger import zed.rainxch.core.domain.model.RateLimitException import zed.rainxch.core.domain.model.RateLimitInfo import zed.rainxch.core.domain.repository.RateLimitRepository class RateLimitInterceptor( private val rateLimitRepository: RateLimitRepository, ) { class Config { var rateLimitRepository: RateLimitRepository? = null } companion object Plugin : HttpClientPlugin { override val key: AttributeKey = AttributeKey("RateLimitInterceptor") override fun prepare(block: Config.() -> Unit): RateLimitInterceptor { val config = Config().apply(block) return RateLimitInterceptor( rateLimitRepository = requireNotNull(config.rateLimitRepository) { "RateLimitRepository must be provided" }, ) } override fun install( plugin: RateLimitInterceptor, scope: HttpClient, ) { scope.receivePipeline.intercept(HttpReceivePipeline.State) { val response = subject parseRateLimitFromHeaders(response.headers)?.let { rateLimitInfo -> plugin.rateLimitRepository.updateRateLimit(rateLimitInfo) if (response.status.value == 403 && rateLimitInfo.isExhausted) { throw RateLimitException(rateLimitInfo) } } proceedWith(subject) } } private fun parseRateLimitFromHeaders(headers: Headers): RateLimitInfo? { return try { val limitHeader = headers["X-RateLimit-Limit"] ?: return null.also { Logger.w { "Missing X-RateLimit-Limit" } } val limit = limitHeader.toIntOrNull() ?: return null.also { Logger.w { "Malformed X-RateLimit-Limit: $limitHeader" } } val remainingHeader = headers["X-RateLimit-Remaining"] ?: return null.also { Logger.w { "Missing X-RateLimit-Remaining" } } val remaining = remainingHeader.toIntOrNull() ?: return null.also { Logger.w { "Malformed X-RateLimit-Remaining: $remainingHeader" } } val resetHeader = headers["X-RateLimit-Reset"] ?: return null.also { Logger.w { "Missing X-RateLimit-Reset" } } val reset = resetHeader.toLongOrNull() ?: return null.also { Logger.w { "Malformed X-RateLimit-Reset: $resetHeader" } } val resource = headers["X-RateLimit-Resource"] ?: "core" RateLimitInfo(limit, remaining, reset, resource) } catch (e: Exception) { Logger.e(e) { "Failed to parse rate limit headers" } null } } } } ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/interceptor/UnauthorizedInterceptor.kt ================================================ package zed.rainxch.core.data.network.interceptor import io.ktor.client.HttpClient import io.ktor.client.plugins.HttpClientPlugin import io.ktor.client.statement.HttpReceivePipeline import io.ktor.util.AttributeKey import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import zed.rainxch.core.domain.repository.AuthenticationState class UnauthorizedInterceptor( private val authenticationState: AuthenticationState, private val scope: CoroutineScope, ) { class Config { var authenticationState: AuthenticationState? = null var scope: CoroutineScope? = null } companion object Plugin : HttpClientPlugin { override val key: AttributeKey = AttributeKey("UnauthorizedInterceptor") override fun prepare(block: Config.() -> Unit): UnauthorizedInterceptor { val config = Config().apply(block) return UnauthorizedInterceptor( authenticationState = requireNotNull(config.authenticationState) { "AuthenticationState must be provided" }, scope = requireNotNull(config.scope) { "CoroutineScope must be provided" }, ) } override fun install( plugin: UnauthorizedInterceptor, scope: HttpClient, ) { scope.receivePipeline.intercept(HttpReceivePipeline.After) { if (subject.status.value == 401) { plugin.scope.launch { plugin.authenticationState.notifySessionExpired() } } proceedWith(subject) } } } } ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/AuthenticationStateImpl.kt ================================================ package zed.rainxch.core.data.repository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import zed.rainxch.core.data.data_source.TokenStore import zed.rainxch.core.domain.repository.AuthenticationState class AuthenticationStateImpl( private val tokenStore: TokenStore, ) : AuthenticationState { private val _sessionExpiredEvent = MutableSharedFlow(extraBufferCapacity = 1) override val sessionExpiredEvent: SharedFlow = _sessionExpiredEvent.asSharedFlow() private val sessionExpiredMutex = Mutex() override fun isUserLoggedIn(): Flow = tokenStore .tokenFlow() .map { it != null } override suspend fun isCurrentlyUserLoggedIn(): Boolean = tokenStore.currentToken() != null override suspend fun notifySessionExpired() { sessionExpiredMutex.withLock { if (tokenStore.currentToken() == null) return@withLock tokenStore.clear() _sessionExpiredEvent.emit(Unit) } } } ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/FavouritesRepositoryImpl.kt ================================================ package zed.rainxch.core.data.repository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import zed.rainxch.core.data.local.db.dao.FavoriteRepoDao import zed.rainxch.core.data.local.db.dao.InstalledAppDao import zed.rainxch.core.data.mappers.toDomain import zed.rainxch.core.data.mappers.toEntity import zed.rainxch.core.domain.model.FavoriteRepo import zed.rainxch.core.domain.repository.FavouritesRepository class FavouritesRepositoryImpl( private val favoriteRepoDao: FavoriteRepoDao, private val installedAppsDao: InstalledAppDao, ) : FavouritesRepository { override fun getAllFavorites(): Flow> = favoriteRepoDao .getAllFavorites() .map { favoriteRepos -> favoriteRepos.map { favoriteRepo -> favoriteRepo.toDomain() } } override fun isFavorite(repoId: Long): Flow = favoriteRepoDao.isFavorite(repoId) override suspend fun isFavoriteSync(repoId: Long): Boolean = favoriteRepoDao.isFavoriteSync(repoId) suspend fun addFavorite(repo: FavoriteRepo) { val installedApp = installedAppsDao.getAppByRepoId(repo.repoId) favoriteRepoDao.insertFavorite( repo .toEntity() .copy( isInstalled = installedApp != null, installedPackageName = installedApp?.packageName, ), ) } override suspend fun toggleFavorite(repo: FavoriteRepo) { if (favoriteRepoDao.isFavoriteSync(repo.repoId)) { favoriteRepoDao.deleteFavoriteById(repo.repoId) } else { addFavorite(repo) } } override suspend fun updateFavoriteInstallStatus( repoId: Long, installed: Boolean, packageName: String?, ) { favoriteRepoDao.updateInstallStatus(repoId, installed, packageName) } } ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt ================================================ package zed.rainxch.core.data.repository import androidx.room.immediateTransaction import androidx.room.useWriterConnection import co.touchlab.kermit.Logger import io.ktor.client.HttpClient import io.ktor.client.request.get import io.ktor.client.request.header import io.ktor.client.request.parameter import io.ktor.http.HttpHeaders import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import zed.rainxch.core.data.dto.ReleaseNetwork import zed.rainxch.core.data.local.db.AppDatabase import zed.rainxch.core.data.local.db.dao.InstalledAppDao import zed.rainxch.core.data.local.db.dao.UpdateHistoryDao import zed.rainxch.core.data.local.db.entities.UpdateHistoryEntity import zed.rainxch.core.data.mappers.toDomain import zed.rainxch.core.data.mappers.toEntity import zed.rainxch.core.data.network.executeRequest import zed.rainxch.core.domain.model.GithubRelease import zed.rainxch.core.domain.model.InstallSource import zed.rainxch.core.domain.model.InstalledApp import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.repository.TweaksRepository import zed.rainxch.core.domain.system.Installer class InstalledAppsRepositoryImpl( private val database: AppDatabase, private val installedAppsDao: InstalledAppDao, private val historyDao: UpdateHistoryDao, private val installer: Installer, private val httpClient: HttpClient, private val tweaksRepository: TweaksRepository, ) : InstalledAppsRepository { override suspend fun executeInTransaction(block: suspend () -> R): R = database.useWriterConnection { transactor -> transactor.immediateTransaction { block() } } override fun getAllInstalledApps(): Flow> = installedAppsDao .getAllInstalledApps() .map { it.map { app -> app.toDomain() } } override fun getAppsWithUpdates(): Flow> = installedAppsDao .getAppsWithUpdates() .map { it.map { app -> app.toDomain() } } override fun getUpdateCount(): Flow = installedAppsDao.getUpdateCount() override suspend fun getAppByPackage(packageName: String): InstalledApp? = installedAppsDao .getAppByPackage(packageName) ?.toDomain() override suspend fun getAppByRepoId(repoId: Long): InstalledApp? = installedAppsDao.getAppByRepoId(repoId)?.toDomain() override fun getAppByRepoIdAsFlow(repoId: Long): Flow = installedAppsDao.getAppByRepoIdAsFlow(repoId).map { it?.toDomain() } override suspend fun isAppInstalled(repoId: Long): Boolean = installedAppsDao.getAppByRepoId(repoId) != null override suspend fun saveInstalledApp(app: InstalledApp) { installedAppsDao.insertApp(app.toEntity()) } override suspend fun deleteInstalledApp(packageName: String) { installedAppsDao.deleteByPackageName(packageName) } private suspend fun fetchLatestPublishedRelease( owner: String, repo: String, ): GithubRelease? { return try { val includePreReleases = tweaksRepository.getIncludePreReleases().first() val releases = httpClient .executeRequest> { get("/repos/$owner/$repo/releases") { header(HttpHeaders.Accept, "application/vnd.github+json") parameter("per_page", 10) } }.getOrNull() ?: return null val latest = releases .asSequence() .filter { it.draft != true } .filter { includePreReleases || it.prerelease != true } .maxByOrNull { it.publishedAt ?: it.createdAt ?: "" } ?: return null latest.toDomain() } catch (e: Exception) { Logger.e { "Failed to fetch latest release for $owner/$repo: ${e.message}" } null } } override suspend fun checkForUpdates(packageName: String): Boolean { val app = installedAppsDao.getAppByPackage(packageName) ?: return false try { val latestRelease = fetchLatestPublishedRelease( owner = app.repoOwner, repo = app.repoName, ) if (latestRelease != null) { val normalizedInstalledTag = normalizeVersion(app.installedVersion) val normalizedLatestTag = normalizeVersion(latestRelease.tagName) val installableAssets = latestRelease.assets.filter { asset -> installer.isAssetInstallable(asset.name) } val primaryAsset = installer.choosePrimaryAsset(installableAssets) // Only flag as update if the latest version is actually newer // (not just different — avoids false "downgrade" notifications) val isUpdateAvailable = if (normalizedInstalledTag == normalizedLatestTag) { false } else { isVersionNewer(normalizedLatestTag, normalizedInstalledTag) } Logger.d { "Update check for ${app.appName}: " + "installedTag=${app.installedVersion}, latestTag=${latestRelease.tagName}, " + "installedCode=${app.installedVersionCode}, " + "isUpdate=$isUpdateAvailable" } installedAppsDao.updateVersionInfo( packageName = packageName, available = isUpdateAvailable, version = latestRelease.tagName, assetName = primaryAsset?.name, assetUrl = primaryAsset?.downloadUrl, assetSize = primaryAsset?.size, releaseNotes = latestRelease.description ?: "", timestamp = System.currentTimeMillis(), latestVersionName = latestRelease.tagName, latestVersionCode = null, ) return isUpdateAvailable } else { installedAppsDao.updateLastChecked(packageName, System.currentTimeMillis()) } } catch (e: Exception) { Logger.e { "Failed to check updates for $packageName: ${e.message}" } installedAppsDao.updateLastChecked(packageName, System.currentTimeMillis()) } return false } override suspend fun checkAllForUpdates() { val apps = installedAppsDao.getAllInstalledApps().first() apps.forEach { app -> if (app.updateCheckEnabled) { try { checkForUpdates(app.packageName) } catch (e: Exception) { Logger.w { "Failed to check updates for ${app.packageName}: ${e.message}" } } } } } override suspend fun updateAppVersion( packageName: String, newTag: String, newAssetName: String, newAssetUrl: String, newVersionName: String, newVersionCode: Long, signingFingerprint: String?, ) { val app = installedAppsDao.getAppByPackage(packageName) ?: return Logger.d { "Updating app version: $packageName from ${app.installedVersion} to $newTag" } historyDao.insertHistory( UpdateHistoryEntity( packageName = packageName, appName = app.appName, repoOwner = app.repoOwner, repoName = app.repoName, fromVersion = app.installedVersion, toVersion = newTag, updatedAt = System.currentTimeMillis(), updateSource = InstallSource.THIS_APP, success = true, ), ) installedAppsDao.updateApp( app.copy( installedVersion = newTag, installedAssetName = newAssetName, installedAssetUrl = newAssetUrl, installedVersionName = newVersionName, installedVersionCode = newVersionCode, latestVersion = newTag, latestAssetName = newAssetName, latestAssetUrl = newAssetUrl, latestVersionName = newVersionName, latestVersionCode = newVersionCode, isUpdateAvailable = false, lastUpdatedAt = System.currentTimeMillis(), lastCheckedAt = System.currentTimeMillis(), signingFingerprint = signingFingerprint, ), ) } override suspend fun updateApp(app: InstalledApp) { installedAppsDao.updateApp(app.toEntity()) } override suspend fun updatePendingStatus( packageName: String, isPending: Boolean, ) { val app = installedAppsDao.getAppByPackage(packageName) ?: return installedAppsDao.updateApp(app.copy(isPendingInstall = isPending)) } private fun normalizeVersion(version: String): String = version.removePrefix("v").removePrefix("V").trim() /** * Compare two version strings and return true if [candidate] is newer than [current]. * Handles semantic versioning (1.2.3), pre-release suffixes (1.2.3-beta.1), * and falls back to lexicographic comparison for non-standard formats. * * Pre-release versions are considered older than their stable counterparts: * 1.2.3-beta < 1.2.3 (per semver spec) * * This prevents false "downgrade" notifications when a user has a pre-release * installed and the latest stable version has a lower or equal base version. */ private fun isVersionNewer( candidate: String, current: String, ): Boolean { val candidateParsed = parseSemanticVersion(candidate) val currentParsed = parseSemanticVersion(current) if (candidateParsed != null && currentParsed != null) { // Compare major.minor.patch for (i in 0 until maxOf(candidateParsed.numbers.size, currentParsed.numbers.size)) { val c = candidateParsed.numbers.getOrElse(i) { 0 } val r = currentParsed.numbers.getOrElse(i) { 0 } if (c > r) return true if (c < r) return false } // Numbers are equal; compare pre-release suffixes // No pre-release > has pre-release (e.g., 1.0.0 > 1.0.0-beta) return when { candidateParsed.preRelease == null && currentParsed.preRelease != null -> { true } candidateParsed.preRelease != null && currentParsed.preRelease == null -> { false } candidateParsed.preRelease != null && currentParsed.preRelease != null -> { comparePreRelease(candidateParsed.preRelease, currentParsed.preRelease) > 0 } else -> { false } // both null, versions are equal } } // Fallback: lexicographic comparison (better than just "not equal") return candidate > current } private data class SemanticVersion( val numbers: List, val preRelease: String?, ) private fun parseSemanticVersion(version: String): SemanticVersion? { // Split off pre-release suffix: "1.2.3-beta.1" -> "1.2.3" and "beta.1" val hyphenIndex = version.indexOf('-') val numberPart = if (hyphenIndex >= 0) version.substring(0, hyphenIndex) else version val preRelease = if (hyphenIndex >= 0) version.substring(hyphenIndex + 1) else null val parts = numberPart.split(".") val numbers = parts.mapNotNull { it.toIntOrNull() } // Only valid if we could parse at least one number and all parts were valid numbers if (numbers.isEmpty() || numbers.size != parts.size) return null return SemanticVersion(numbers, preRelease) } /** * Compare pre-release identifiers per semver spec: * Identifiers consisting of only digits are compared numerically. * Identifiers with letters are compared lexically. * Numeric identifiers always have lower precedence than alphanumeric. * A larger set of pre-release fields has higher precedence if all preceding are equal. */ private fun comparePreRelease( a: String, b: String, ): Int { val aParts = a.split(".") val bParts = b.split(".") for (i in 0 until minOf(aParts.size, bParts.size)) { val aNum = aParts[i].toIntOrNull() val bNum = bParts[i].toIntOrNull() val cmp = when { aNum != null && bNum != null -> aNum.compareTo(bNum) aNum != null -> -1 // numeric < alphanumeric bNum != null -> 1 else -> aParts[i].compareTo(bParts[i]) } if (cmp != 0) return cmp } return aParts.size.compareTo(bParts.size) } } ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ProxyRepositoryImpl.kt ================================================ package zed.rainxch.core.data.repository import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import zed.rainxch.core.data.network.ProxyManager import zed.rainxch.core.domain.model.ProxyConfig import zed.rainxch.core.domain.repository.ProxyRepository class ProxyRepositoryImpl( private val preferences: DataStore, ) : ProxyRepository { private val proxyTypeKey = stringPreferencesKey("proxy_type") private val proxyHostKey = stringPreferencesKey("proxy_host") private val proxyPortKey = intPreferencesKey("proxy_port") private val proxyUsernameKey = stringPreferencesKey("proxy_username") private val proxyPasswordKey = stringPreferencesKey("proxy_password") override fun getProxyConfig(): Flow = preferences.data.map { prefs -> when (prefs[proxyTypeKey]) { "system" -> { ProxyConfig.System } "http" -> { val host = prefs[proxyHostKey]?.takeIf { it.isNotBlank() } val port = prefs[proxyPortKey]?.takeIf { it in 1..65535 } if (host != null && port != null) { ProxyConfig.Http( host = host, port = port, username = prefs[proxyUsernameKey], password = prefs[proxyPasswordKey], ) } else { ProxyConfig.None } } "socks" -> { val host = prefs[proxyHostKey]?.takeIf { it.isNotBlank() } val port = prefs[proxyPortKey]?.takeIf { it in 1..65535 } if (host != null && port != null) { ProxyConfig.Socks( host = host, port = port, username = prefs[proxyUsernameKey], password = prefs[proxyPasswordKey], ) } else { ProxyConfig.None } } else -> { ProxyConfig.None } } } override suspend fun setProxyConfig(config: ProxyConfig) { // Persist first so config survives crashes, then apply in-memory preferences.edit { prefs -> when (config) { is ProxyConfig.None -> { prefs[proxyTypeKey] = "none" prefs.remove(proxyHostKey) prefs.remove(proxyPortKey) prefs.remove(proxyUsernameKey) prefs.remove(proxyPasswordKey) } is ProxyConfig.System -> { prefs[proxyTypeKey] = "system" prefs.remove(proxyHostKey) prefs.remove(proxyPortKey) prefs.remove(proxyUsernameKey) prefs.remove(proxyPasswordKey) } is ProxyConfig.Http -> { prefs[proxyTypeKey] = "http" prefs[proxyHostKey] = config.host prefs[proxyPortKey] = config.port if (config.username != null) { prefs[proxyUsernameKey] = config.username!! } else { prefs.remove(proxyUsernameKey) } if (config.password != null) { prefs[proxyPasswordKey] = config.password!! } else { prefs.remove(proxyPasswordKey) } } is ProxyConfig.Socks -> { prefs[proxyTypeKey] = "socks" prefs[proxyHostKey] = config.host prefs[proxyPortKey] = config.port if (config.username != null) { prefs[proxyUsernameKey] = config.username!! } else { prefs.remove(proxyUsernameKey) } if (config.password != null) { prefs[proxyPasswordKey] = config.password!! } else { prefs.remove(proxyPasswordKey) } } } } applyToProxyManager(config) } private fun applyToProxyManager(config: ProxyConfig) { when (config) { is ProxyConfig.None -> { ProxyManager.setNoProxy() } is ProxyConfig.System -> { ProxyManager.setSystemProxy() } is ProxyConfig.Http -> { ProxyManager.setHttpProxy( host = config.host, port = config.port, username = config.username, password = config.password, ) } is ProxyConfig.Socks -> { ProxyManager.setSocksProxy( host = config.host, port = config.port, username = config.username, password = config.password, ) } } } } ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/RateLimitRepositoryImpl.kt ================================================ package zed.rainxch.core.data.repository import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import zed.rainxch.core.domain.model.RateLimitInfo import zed.rainxch.core.domain.repository.RateLimitRepository import kotlin.time.Clock class RateLimitRepositoryImpl : RateLimitRepository { private val _rateLimitState = MutableStateFlow(null) override val rateLimitState: StateFlow = _rateLimitState.asStateFlow() private val _rateLimitExhaustedEvent = MutableSharedFlow(extraBufferCapacity = 1) override val rateLimitExhaustedEvent: SharedFlow = _rateLimitExhaustedEvent override fun updateRateLimit(rateLimitInfo: RateLimitInfo?) { _rateLimitState.value = rateLimitInfo if (rateLimitInfo?.isExhausted == true) { _rateLimitExhaustedEvent.tryEmit(rateLimitInfo) } } override fun getCurrentRateLimit(): RateLimitInfo? = _rateLimitState.value override fun isCurrentlyLimited(): Boolean { val info = getCurrentRateLimit() ?: return false return info.isCurrentlyLimited() } override fun clear() { _rateLimitState.value = null } } ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/SeenReposRepositoryImpl.kt ================================================ package zed.rainxch.core.data.repository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import zed.rainxch.core.data.local.db.dao.SeenRepoDao import zed.rainxch.core.data.local.db.entities.SeenRepoEntity import zed.rainxch.core.domain.repository.SeenReposRepository class SeenReposRepositoryImpl( private val seenRepoDao: SeenRepoDao, ) : SeenReposRepository { override fun getAllSeenRepoIds(): Flow> = seenRepoDao.getAllSeenRepoIds().map { it.toSet() } override suspend fun markAsSeen(repoId: Long) { seenRepoDao.insert( SeenRepoEntity( repoId = repoId, seenAt = System.currentTimeMillis(), ), ) } override suspend fun clearAll() { seenRepoDao.clearAll() } } ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/StarredRepositoryImpl.kt ================================================ @file:OptIn(ExperimentalTime::class) package zed.rainxch.core.data.repository import co.touchlab.kermit.Logger import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.request.get import io.ktor.client.request.header import io.ktor.client.request.parameter import io.ktor.http.isSuccess import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import zed.rainxch.core.data.dto.GitHubStarredResponse import zed.rainxch.core.data.local.db.dao.InstalledAppDao import zed.rainxch.core.data.local.db.dao.StarredRepoDao import zed.rainxch.core.data.mappers.toDomain import zed.rainxch.core.data.mappers.toEntity import zed.rainxch.core.domain.model.Platform import zed.rainxch.core.domain.model.RateLimitException import zed.rainxch.core.domain.repository.StarredRepository import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Clock import kotlin.time.ExperimentalTime import kotlin.time.Instant class StarredRepositoryImpl( private val starredRepoDao: StarredRepoDao, private val installedAppsDao: InstalledAppDao, private val platform: Platform, private val httpClient: HttpClient, ) : StarredRepository { companion object { private const val SYNC_THRESHOLD_MS = 24 * 60 * 60 * 1000L // 24 hours } override fun getAllStarred(): Flow> = starredRepoDao .getAllStarred() .map { it.map { entity -> entity.toDomain() } } override suspend fun isStarred(repoId: Long): Boolean = starredRepoDao.isStarred(repoId) override suspend fun getLastSyncTime(): Long? = starredRepoDao.getLastSyncTime() override suspend fun needsSync(): Boolean { val lastSync = getLastSyncTime() ?: return true val now = Clock.System.now().toEpochMilliseconds() return (now - lastSync) > SYNC_THRESHOLD_MS } override suspend fun syncStarredRepos(forceRefresh: Boolean): Result = withContext(Dispatchers.IO) { try { if (!forceRefresh && !needsSync()) { return@withContext Result.success(Unit) } val allRepos = mutableListOf() var page = 1 val perPage = 100 while (true) { val response = httpClient.get("/user/starred") { parameter("per_page", perPage) parameter("page", page) } if (!response.status.isSuccess()) { if (response.status.value == 401) { return@withContext Result.failure( Exception("Authentication required. Please sign in with GitHub."), ) } return@withContext Result.failure( Exception("Failed to fetch starred repos: ${response.status.description}"), ) } val repos: List = response.body() if (repos.isEmpty()) break allRepos.addAll(repos) if (repos.size < perPage) break page++ } val now = Clock.System.now().toEpochMilliseconds() val starredRepos = mutableListOf() coroutineScope { val semaphore = Semaphore(25) val deferredResults = allRepos.map { repo -> async { semaphore.withPermit { val hasValidAssets = checkForValidAssets(repo.owner.login, repo.name) if (hasValidAssets) { val installedApp = installedAppsDao.getAppByRepoId(repo.id) zed.rainxch.core.domain.model.StarredRepository( repoId = repo.id, repoName = repo.name, repoOwner = repo.owner.login, repoOwnerAvatarUrl = repo.owner.avatarUrl, repoDescription = repo.description, primaryLanguage = repo.language, repoUrl = repo.htmlUrl, stargazersCount = repo.stargazersCount, forksCount = repo.forksCount, openIssuesCount = repo.openIssuesCount, isInstalled = installedApp != null, installedPackageName = installedApp?.packageName, latestVersion = null, latestReleaseUrl = null, starredAt = repo.starredAt?.let { Instant.parse(it).toEpochMilliseconds() }, addedAt = now, lastSyncedAt = now, ) } else { null } } } } deferredResults.awaitAll().filterNotNull().let { validRepos -> starredRepos.addAll(validRepos) } } starredRepoDao.replaceAllStarred(starredRepos.map { it.toEntity() }) Result.success(Unit) } catch (e: RateLimitException) { throw e } catch (e: CancellationException) { throw e } catch (e: Exception) { Logger.e(e) { "Failed to sync starred repos" } Result.failure(e) } } private suspend fun checkForValidAssets( owner: String, repo: String, ): Boolean { return try { val releasesResponse = httpClient.get("/repos/$owner/$repo/releases") { header("Accept", "application/vnd.github.v3+json") parameter("per_page", 10) } if (!releasesResponse.status.isSuccess()) { return false } val allReleases: List = releasesResponse.body() val stableRelease = allReleases.firstOrNull { it.draft != true && it.prerelease != true } ?: return false if (stableRelease.assets.isEmpty()) { return false } val relevantAssets = stableRelease.assets.filter { asset -> val name = asset.name.lowercase() when (platform) { Platform.ANDROID -> { name.endsWith(".apk") } Platform.WINDOWS -> { name.endsWith(".msi") || name.endsWith(".exe") } Platform.MACOS -> { name.endsWith(".dmg") || name.endsWith(".pkg") } Platform.LINUX -> { name.endsWith(".appimage") || name.endsWith(".deb") || name.endsWith( ".rpm", ) } } } relevantAssets.isNotEmpty() } catch (e: RateLimitException) { throw e } catch (e: CancellationException) { throw e } catch (e: Exception) { Logger.w(e) { "Failed to check valid assets for $owner/$repo" } false } } @Serializable private data class GithubReleaseNetworkModel( val assets: List, val draft: Boolean? = null, val prerelease: Boolean? = null, @SerialName("published_at") val publishedAt: String? = null, ) @Serializable private data class AssetNetworkModel( val name: String, ) } ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt ================================================ package zed.rainxch.core.data.repository import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import zed.rainxch.core.domain.model.AppTheme import zed.rainxch.core.domain.model.FontTheme import zed.rainxch.core.domain.model.InstallerType import zed.rainxch.core.domain.repository.TweaksRepository class TweaksRepositoryImpl( private val preferences: DataStore, ) : TweaksRepository { private val THEME_KEY = stringPreferencesKey("app_theme") private val AMOLED_KEY = booleanPreferencesKey("amoled_theme") private val IS_DARK_THEME_KEY = booleanPreferencesKey("is_dark_theme") private val FONT_KEY = stringPreferencesKey("font_theme") private val AUTO_DETECT_CLIPBOARD_KEY = booleanPreferencesKey("auto_detect_clipboard_links") private val INSTALLER_TYPE_KEY = stringPreferencesKey("installer_type") private val AUTO_UPDATE_KEY = booleanPreferencesKey("auto_update_enabled") private val UPDATE_CHECK_INTERVAL_KEY = longPreferencesKey("update_check_interval_hours") private val INCLUDE_PRE_RELEASES_KEY = booleanPreferencesKey("include_pre_releases") private val LIQUID_GLASS_ENABLED_KEY = booleanPreferencesKey("liquid_glass_enabled") private val HIDE_SEEN_ENABLED_KEY = booleanPreferencesKey("hide_seen_enabled") override fun getThemeColor(): Flow = preferences.data.map { prefs -> val themeName = prefs[THEME_KEY] AppTheme.fromName(themeName) } override suspend fun setThemeColor(theme: AppTheme) { preferences.edit { prefs -> prefs[THEME_KEY] = theme.name } } override fun getIsDarkTheme(): Flow = preferences.data.map { prefs -> prefs[IS_DARK_THEME_KEY] } override suspend fun setDarkTheme(isDarkTheme: Boolean?) { preferences.edit { prefs -> if (isDarkTheme == null) { prefs.remove(IS_DARK_THEME_KEY) } else { prefs[IS_DARK_THEME_KEY] = isDarkTheme } } } override fun getAmoledTheme(): Flow = preferences.data.map { prefs -> prefs[AMOLED_KEY] ?: false } override suspend fun setAmoledTheme(enabled: Boolean) { preferences.edit { prefs -> prefs[AMOLED_KEY] = enabled } } override fun getFontTheme(): Flow = preferences.data.map { prefs -> val fontName = prefs[FONT_KEY] FontTheme.fromName(fontName) } override suspend fun setFontTheme(fontTheme: FontTheme) { preferences.edit { prefs -> prefs[FONT_KEY] = fontTheme.name } } override fun getAutoDetectClipboardLinks(): Flow = preferences.data.map { prefs -> prefs[AUTO_DETECT_CLIPBOARD_KEY] ?: false } override suspend fun setAutoDetectClipboardLinks(enabled: Boolean) { preferences.edit { prefs -> prefs[AUTO_DETECT_CLIPBOARD_KEY] = enabled } } override fun getInstallerType(): Flow = preferences.data.map { prefs -> val name = prefs[INSTALLER_TYPE_KEY] InstallerType.fromName(name) } override suspend fun setInstallerType(type: InstallerType) { preferences.edit { prefs -> prefs[INSTALLER_TYPE_KEY] = type.name } } override fun getAutoUpdateEnabled(): Flow = preferences.data.map { prefs -> prefs[AUTO_UPDATE_KEY] ?: false } override suspend fun setAutoUpdateEnabled(enabled: Boolean) { preferences.edit { prefs -> prefs[AUTO_UPDATE_KEY] = enabled } } override fun getUpdateCheckInterval(): Flow = preferences.data.map { prefs -> prefs[UPDATE_CHECK_INTERVAL_KEY] ?: DEFAULT_UPDATE_CHECK_INTERVAL_HOURS } override suspend fun setUpdateCheckInterval(hours: Long) { preferences.edit { prefs -> prefs[UPDATE_CHECK_INTERVAL_KEY] = hours } } override fun getIncludePreReleases(): Flow = preferences.data.map { prefs -> prefs[INCLUDE_PRE_RELEASES_KEY] ?: false } override suspend fun setIncludePreReleases(enabled: Boolean) { preferences.edit { prefs -> prefs[INCLUDE_PRE_RELEASES_KEY] = enabled } } override fun getLiquidGlassEnabled(): Flow = preferences.data.map { prefs -> prefs[LIQUID_GLASS_ENABLED_KEY] ?: true } override suspend fun setLiquidGlassEnabled(enabled: Boolean) { preferences.edit { prefs -> prefs[LIQUID_GLASS_ENABLED_KEY] = enabled } } override fun getHideSeenEnabled(): Flow = preferences.data.map { prefs -> prefs[HIDE_SEEN_ENABLED_KEY] ?: false } override suspend fun setHideSeenEnabled(enabled: Boolean) { preferences.edit { prefs -> prefs[HIDE_SEEN_ENABLED_KEY] = enabled } } companion object { const val DEFAULT_UPDATE_CHECK_INTERVAL_HOURS = 6L } } ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/FileLocationsProvider.kt ================================================ package zed.rainxch.core.data.services interface FileLocationsProvider { fun appDownloadsDir(): String fun userDownloadsDir(): String fun setExecutableIfNeeded(path: String) fun getCacheSizeBytes(): Long fun clearCacheFiles(): Boolean } ================================================ FILE: core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/LocalizationManager.kt ================================================ package zed.rainxch.core.data.services interface LocalizationManager { /** * Returns the current device language code in ISO 639-1 format (e.g., "en", "zh", "ja") * Can include region code if available (e.g., "zh-CN", "pt-BR") */ fun getCurrentLanguageCode(): String /** * Returns the primary language code without region (e.g., "zh" from "zh-CN") */ fun getPrimaryLanguageCode(): String } ================================================ FILE: core/data/src/jvmMain/kotlin/zed/rainxch/core/data/di/PlatformModule.jvm.kt ================================================ package zed.rainxch.core.data.di import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import org.koin.dsl.module import zed.rainxch.core.data.local.data_store.createDataStore import zed.rainxch.core.data.local.db.AppDatabase import zed.rainxch.core.data.local.db.initDatabase import zed.rainxch.core.data.services.DesktopInstallerInfoExtractor import zed.rainxch.core.data.utils.DesktopAppLauncher import zed.rainxch.core.data.utils.DesktopBrowserHelper import zed.rainxch.core.data.utils.DesktopClipboardHelper import zed.rainxch.core.data.services.DesktopDownloader import zed.rainxch.core.data.services.DesktopFileLocationsProvider import zed.rainxch.core.data.services.DesktopInstaller import zed.rainxch.core.data.services.DesktopLocalizationManager import zed.rainxch.core.data.services.DesktopPackageMonitor import zed.rainxch.core.data.services.DesktopUpdateScheduleManager import zed.rainxch.core.data.services.FileLocationsProvider import zed.rainxch.core.domain.system.Installer import zed.rainxch.core.domain.system.InstallerStatusProvider import zed.rainxch.core.domain.system.UpdateScheduleManager import zed.rainxch.core.data.services.LocalizationManager import zed.rainxch.core.data.services.DesktopInstallerStatusProvider import zed.rainxch.core.data.utils.DesktopShareManager import zed.rainxch.core.domain.network.Downloader import zed.rainxch.core.domain.system.PackageMonitor import zed.rainxch.core.domain.utils.AppLauncher import zed.rainxch.core.domain.utils.BrowserHelper import zed.rainxch.core.domain.utils.ClipboardHelper import zed.rainxch.core.domain.utils.ShareManager actual val corePlatformModule = module { // Core single { DesktopDownloader( files = get(), ) } single { DesktopInstaller( platform = get(), installerInfoExtractor = DesktopInstallerInfoExtractor(), ) } single { DesktopFileLocationsProvider( platform = get() ) } single { DesktopPackageMonitor() } single { DesktopLocalizationManager() } // Locals single { initDatabase() } single> { createDataStore() } // Utils single { DesktopBrowserHelper() } single { DesktopClipboardHelper() } single { DesktopAppLauncher( logger = get(), platform = get() ) } single { DesktopShareManager() } single { DesktopInstallerStatusProvider() } single { DesktopUpdateScheduleManager() } } ================================================ FILE: core/data/src/jvmMain/kotlin/zed/rainxch/core/data/local/data_store/createDataStore.kt ================================================ package zed.rainxch.core.data.local.data_store import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import java.io.File fun createDataStore(): DataStore = createDataStore( producePath = { val file = File(System.getProperty("java.io.tmpdir"), dataStoreFileName) file.absolutePath }, ) ================================================ FILE: core/data/src/jvmMain/kotlin/zed/rainxch/core/data/local/db/initDatabase.kt ================================================ package zed.rainxch.core.data.local.db import androidx.room.Room import androidx.sqlite.driver.bundled.BundledSQLiteDriver import kotlinx.coroutines.Dispatchers import java.io.File fun initDatabase(): AppDatabase { val dbFile = File(System.getProperty("java.io.tmpdir"), "github_store.db") return Room .databaseBuilder( name = dbFile.absolutePath, ).setDriver(BundledSQLiteDriver()) .setQueryCoroutineContext(Dispatchers.IO) .fallbackToDestructiveMigration(true) .build() } ================================================ FILE: core/data/src/jvmMain/kotlin/zed/rainxch/core/data/model/LinuxPackageType.kt ================================================ package zed.rainxch.core.data.model enum class LinuxPackageType { DEB, // Debian/Ubuntu/Mint RPM, // Fedora/RHEL/CentOS/openSUSE UNIVERSAL, // Unknown - show AppImage only } ================================================ FILE: core/data/src/jvmMain/kotlin/zed/rainxch/core/data/model/LinuxTerminal.kt ================================================ package zed.rainxch.core.data.model enum class LinuxTerminal { GNOME_TERMINAL, KONSOLE, XTERM, XFCE4_TERMINAL, ALACRITTY, KITTY, TILIX, MATE_TERMINAL, } ================================================ FILE: core/data/src/jvmMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.jvm.kt ================================================ package zed.rainxch.core.data.network import io.ktor.client.HttpClient import io.ktor.client.engine.ProxyBuilder import io.ktor.client.engine.okhttp.OkHttp import io.ktor.http.Url import zed.rainxch.core.domain.model.ProxyConfig import java.net.ProxySelector import java.net.URI actual fun createPlatformHttpClient(proxyConfig: ProxyConfig): HttpClient = HttpClient(OkHttp) { engine { proxy = when (proxyConfig) { is ProxyConfig.None -> { null } is ProxyConfig.System -> { val systemProxy = ProxySelector .getDefault() ?.select(URI("https://api.github.com")) ?.firstOrNull { it.type() != java.net.Proxy.Type.DIRECT } if (systemProxy != null) { val addr = systemProxy.address() as? java.net.InetSocketAddress if (addr != null) { when (systemProxy.type()) { java.net.Proxy.Type.HTTP -> { ProxyBuilder.http(Url("http://${addr.hostString}:${addr.port}")) } java.net.Proxy.Type.SOCKS -> { ProxyBuilder.socks(addr.hostString, addr.port) } else -> { null } } } else { null } } else { null } } is ProxyConfig.Http -> { ProxyBuilder.http(Url("http://${proxyConfig.host}:${proxyConfig.port}")) } is ProxyConfig.Socks -> { ProxyBuilder.socks(proxyConfig.host, proxyConfig.port) } } } } ================================================ FILE: core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopDownloader.kt ================================================ package zed.rainxch.core.data.services import co.touchlab.kermit.Logger import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.withContext import okhttp3.Call import okhttp3.Credentials import okhttp3.OkHttpClient import okhttp3.Request import zed.rainxch.core.data.network.ProxyManager import zed.rainxch.core.domain.model.DownloadProgress import zed.rainxch.core.domain.model.ProxyConfig import zed.rainxch.core.domain.network.Downloader import java.io.File import java.net.Authenticator import java.net.InetSocketAddress import java.net.PasswordAuthentication import java.net.Proxy import java.util.UUID import java.util.concurrent.ConcurrentHashMap class DesktopDownloader( private val files: FileLocationsProvider, private val proxyManager: ProxyManager = ProxyManager, ) : Downloader { private val activeDownloads = ConcurrentHashMap() private val nameToId = ConcurrentHashMap() private fun buildClient(): OkHttpClient { Authenticator.setDefault(null) return OkHttpClient .Builder() .apply { when (val config = proxyManager.currentProxyConfig.value) { is ProxyConfig.None -> { proxy(Proxy.NO_PROXY) } is ProxyConfig.System -> {} is ProxyConfig.Http -> { proxy(Proxy(Proxy.Type.HTTP, InetSocketAddress(config.host, config.port))) if (config.username != null && config.password != null) { proxyAuthenticator { _, response -> response.request .newBuilder() .header( "Proxy-Authorization", Credentials.basic(config.username!!, config.password!!), ).build() } } } is ProxyConfig.Socks -> { proxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress(config.host, config.port))) if (config.username != null && config.password != null) { Authenticator.setDefault( object : Authenticator() { override fun getPasswordAuthentication() = PasswordAuthentication( config.username, config.password!!.toCharArray(), ) }, ) } } } }.build() } override fun download( url: String, suggestedFileName: String?, ): Flow = flow { val client = buildClient() val dir = File(files.userDownloadsDir()) if (!dir.exists()) dir.mkdirs() val rawName = suggestedFileName?.takeIf { it.isNotBlank() } ?: url .substringAfterLast('/') .substringBefore('?') .substringBefore('#') .ifBlank { "asset-${UUID.randomUUID()}" } val safeName = rawName.substringAfterLast('/').substringAfterLast('\\') require(safeName.isNotBlank() && safeName != "." && safeName != "..") { "Invalid file name: $rawName" } val downloadId = UUID.randomUUID().toString() val previous = nameToId.putIfAbsent(safeName, downloadId) if (previous != null) { throw IllegalStateException("A download for '$safeName' is already in progress") } val destination = File(dir, safeName) if (destination.exists()) { Logger.d { "Deleting existing file before download: ${destination.absolutePath}" } destination.delete() } Logger.d { "Starting download: $url" } val request = Request.Builder().url(url).build() val call = client.newCall(request) activeDownloads[downloadId] = call try { call.execute().use { response -> if (!response.isSuccessful) { throw kotlinx.io.IOException("Unexpected code ${response.code}") } val body = response.body val contentLength = body.contentLength() val total = if (contentLength > 0) contentLength else null body.byteStream().use { input -> destination.outputStream().use { output -> val buffer = ByteArray(DEFAULT_BUFFER_SIZE) var downloaded: Long = 0 var bytesRead: Int while (input.read(buffer).also { bytesRead = it } != -1) { output.write(buffer, 0, bytesRead) downloaded += bytesRead val percent = if (total != null) ((downloaded * 100L) / total).toInt() else null emit(DownloadProgress(downloaded, total, percent)) } } } if (destination.exists() && destination.length() > 0) { Logger.d { "Download complete: ${destination.absolutePath}" } val finalDownloaded = destination.length() val finalPercent = if (total != null) ((finalDownloaded * 100L) / total).toInt() else 100 emit(DownloadProgress(finalDownloaded, total, finalPercent)) } else { throw IllegalStateException("File not ready after download: ${destination.absolutePath}") } } } catch (e: Exception) { destination.delete() Logger.e(e) { "Download failed" } throw e } finally { activeDownloads.remove(downloadId) nameToId.remove(safeName) } }.flowOn(Dispatchers.IO) override suspend fun saveToFile( url: String, suggestedFileName: String?, ): String = withContext(Dispatchers.IO) { val rawName = suggestedFileName?.takeIf { it.isNotBlank() } ?: url .substringAfterLast('/') .substringBefore('?') .substringBefore('#') .ifBlank { "asset-${UUID.randomUUID()}" } val safeName = rawName.substringAfterLast('/').substringAfterLast('\\') require(safeName.isNotBlank() && safeName != "." && safeName != "..") { "Invalid file name: $rawName" } val file = File(files.userDownloadsDir(), safeName) if (file.exists()) { Logger.d { "Deleting existing file before download: ${file.absolutePath}" } file.delete() } Logger.d { "saveToFile downloading file..." } download(url, suggestedFileName).collect { } file.absolutePath } override suspend fun getDownloadedFilePath(fileName: String): String? = withContext(Dispatchers.IO) { val file = File(files.userDownloadsDir(), fileName) if (file.exists() && file.length() > 0) file.absolutePath else null } override suspend fun cancelDownload(fileName: String): Boolean = withContext(Dispatchers.IO) { var cancelled = false var deleted = false val downloadId = nameToId[fileName] if (downloadId != null) { activeDownloads[downloadId]?.let { call -> if (!call.isCanceled()) { call.cancel() cancelled = true } } activeDownloads.remove(downloadId) nameToId.remove(fileName) } val file = File(files.userDownloadsDir(), fileName) if (file.exists()) { deleted = file.delete() } cancelled || deleted } companion object { private const val DEFAULT_BUFFER_SIZE = 8 * 1024 } } ================================================ FILE: core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopFileLocationsProvider.kt ================================================ package zed.rainxch.core.data.services import co.touchlab.kermit.Logger import zed.rainxch.core.domain.model.Platform import java.io.File import java.nio.file.Files import java.nio.file.attribute.PosixFilePermission class DesktopFileLocationsProvider( private val platform: Platform, ) : FileLocationsProvider { override fun appDownloadsDir(): String { val baseDir = when (platform) { Platform.WINDOWS -> { val appData = System.getenv("LOCALAPPDATA") ?: (System.getProperty("user.home") + "\\AppData\\Local") File(appData, "GithubStore\\Downloads") } Platform.MACOS -> { val home = System.getProperty("user.home") File(home, "Library/Caches/GithubStore/Downloads") } Platform.LINUX -> { val cacheHome = System.getenv("XDG_CACHE_HOME") ?: (System.getProperty("user.home") + "/.cache") File(cacheHome, "githubstore/downloads") } else -> { File(System.getProperty("user.home"), ".githubstore/downloads") } } if (!baseDir.exists()) { baseDir.mkdirs() } return baseDir.absolutePath } override fun setExecutableIfNeeded(path: String) { if (platform == Platform.LINUX || platform == Platform.MACOS) { try { val file = File(path) val filePath = file.toPath() val perms = Files.getPosixFilePermissions(filePath).toMutableSet() perms.add(PosixFilePermission.OWNER_EXECUTE) perms.add(PosixFilePermission.GROUP_EXECUTE) perms.add(PosixFilePermission.OTHERS_EXECUTE) Files.setPosixFilePermissions(filePath, perms) } catch (e: Exception) { try { Runtime.getRuntime().exec(arrayOf("chmod", "+x", path)).waitFor() } catch (e2: Exception) { println("Warning: Could not set executable permission on $path") } } } } override fun userDownloadsDir(): String { val appSubdirName = "GitHub Store Downloads" val downloadsDir = when (platform) { Platform.WINDOWS -> { val userProfile = System.getenv("USERPROFILE") ?: System.getProperty("user.home") File(userProfile, "Downloads").resolve(appSubdirName) } Platform.MACOS -> { val home = System.getProperty("user.home") File(home, "Downloads").resolve(appSubdirName) } Platform.LINUX -> { val xdgDownloads = getXdgDownloadsDir() val baseDir = if (xdgDownloads != null) { File(xdgDownloads) } else { val home = System.getProperty("user.home") File(home, "Downloads") } baseDir.resolve(appSubdirName) } else -> { File(System.getProperty("user.home"), "Downloads").resolve(appSubdirName) } } if (!downloadsDir.exists()) { downloadsDir.mkdirs() } return downloadsDir.absolutePath } override fun getCacheSizeBytes(): Long { val appDir = File(appDownloadsDir()) val userDir = File(userDownloadsDir()) return calculateDirSize(appDir) + calculateDirSize(userDir) } override fun clearCacheFiles(): Boolean { val appDir = File(appDownloadsDir()) val userDir = File(userDownloadsDir()) val appCleared = deleteDirectoryContents(appDir) val userCleared = deleteDirectoryContents(userDir) return appCleared && userCleared } private fun calculateDirSize(dir: File): Long { if (!dir.exists()) return 0L var size = 0L dir.listFiles()?.forEach { file -> size += if (file.isDirectory) calculateDirSize(file) else file.length() } return size } private fun deleteDirectoryContents(dir: File): Boolean { if (!dir.exists()) return true var allDeleted = true dir.listFiles()?.forEach { file -> if (file.isDirectory) { if (!deleteDirectoryContents(file)) allDeleted = false if (!file.delete()) allDeleted = false } else { if (!file.delete()) allDeleted = false } } return allDeleted } private fun getXdgDownloadsDir(): String? { return try { val userDirsFile = File( System.getProperty("user.home"), ".config/user-dirs.dirs", ) if (userDirsFile.exists()) { userDirsFile.readLines().forEach { line -> if (line.trim().startsWith("XDG_DOWNLOAD_DIR=")) { val path = line .substringAfter("=") .trim() .removeSurrounding("\"") .replace("\$HOME", System.getProperty("user.home")) return path } } } null } catch (e: Exception) { Logger.w { "Failed to read XDG user dirs: ${e.message}" } null } } } ================================================ FILE: core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopInstaller.kt ================================================ package zed.rainxch.core.data.services import co.touchlab.kermit.Logger import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import zed.rainxch.core.data.model.LinuxPackageType import zed.rainxch.core.data.model.LinuxTerminal import zed.rainxch.core.domain.model.AssetArchitectureMatcher import zed.rainxch.core.domain.model.GithubAsset import zed.rainxch.core.domain.model.Platform import zed.rainxch.core.domain.model.SystemArchitecture import zed.rainxch.core.domain.system.Installer import zed.rainxch.core.domain.system.InstallerInfoExtractor import java.awt.Desktop import java.awt.Toolkit import java.awt.datatransfer.StringSelection import java.io.File import java.io.IOException import kotlin.collections.iterator import kotlin.getValue class DesktopInstaller( private val platform: Platform, private val installerInfoExtractor: InstallerInfoExtractor, ) : Installer { private val linuxPackageType: LinuxPackageType by lazy { determineLinuxPackageType() } private val systemArchitecture: SystemArchitecture by lazy { determineSystemArchitecture() } override fun getApkInfoExtractor(): InstallerInfoExtractor = installerInfoExtractor override fun detectSystemArchitecture(): SystemArchitecture = systemArchitecture override fun isObtainiumInstalled(): Boolean = false override fun openInObtainium( repoOwner: String, repoName: String, onOpenInstaller: () -> Unit, ) { } override fun isAppManagerInstalled(): Boolean = false override fun openInAppManager( filePath: String, onOpenInstaller: () -> Unit, ) { } override fun openApp(packageName: String): Boolean { // Desktop apps are launched differently per platform Logger.d { "Open app not supported on desktop for: $packageName" } return false } override fun openWithExternalInstaller(filePath: String) { // Not applicable on desktop } override fun isAssetInstallable(assetName: String): Boolean { val name = assetName.lowercase() val hasValidExtension = when (platform) { Platform.ANDROID -> { name.endsWith(".apk") } Platform.WINDOWS -> { name.endsWith(".msi") || name.endsWith(".exe") } Platform.MACOS -> { name.endsWith(".dmg") || name.endsWith(".pkg") } Platform.LINUX -> { name.endsWith(".appimage") || name.endsWith(".deb") || name.endsWith(".rpm") } } if (!hasValidExtension) return false return isArchitectureCompatible(name, systemArchitecture) } override fun choosePrimaryAsset(assets: List): GithubAsset? { if (assets.isEmpty()) return null val priority = when (platform) { Platform.ANDROID -> { listOf(".apk") } Platform.WINDOWS -> { listOf(".msi", ".exe") } Platform.MACOS -> { listOf(".dmg", ".pkg") } Platform.LINUX -> { when (linuxPackageType) { LinuxPackageType.DEB -> listOf(".appimage", ".deb", ".rpm") LinuxPackageType.RPM -> listOf(".appimage", ".rpm", ".deb") LinuxPackageType.UNIVERSAL -> listOf(".appimage", ".deb", ".rpm") } } } val compatibleAssets = assets.filter { asset -> isArchitectureCompatible(asset.name.lowercase(), systemArchitecture) } val assetsToConsider = compatibleAssets.ifEmpty { assets } return assetsToConsider.maxByOrNull { asset -> val name = asset.name.lowercase() val extensionIdx = priority.indexOfFirst { name.endsWith(it) } val extensionScore = if (extensionIdx == -1) { -100000 } else { (priority.size - extensionIdx) * 10000 } val archScore = if (isExactArchitectureMatch(name, systemArchitecture)) { 1000 } else { 0 } val sizeScore = (asset.size / 1000000).coerceAtMost(100) extensionScore + archScore + sizeScore } } private fun determineSystemArchitecture(): SystemArchitecture { if (platform == Platform.MACOS) { try { val process = ProcessBuilder("uname", "-m").start() val output = process.inputStream .bufferedReader() .readText() .trim() process.waitFor() return when (output) { "arm64" -> SystemArchitecture.AARCH64 "x86_64" -> SystemArchitecture.X86_64 else -> SystemArchitecture.fromString(System.getProperty("os.arch")) } } catch (_: Exception) { } } val osArch = System.getProperty("os.arch") ?: return SystemArchitecture.UNKNOWN return SystemArchitecture.fromString(osArch) } private fun determineLinuxPackageType(): LinuxPackageType { if (platform != Platform.LINUX) return LinuxPackageType.UNIVERSAL return try { val osRelease = tryReadOsRelease() if (osRelease != null) { val idLike = osRelease["ID_LIKE"]?.lowercase() ?: "" val id = osRelease["ID"]?.lowercase() ?: "" if (id in listOf("debian", "ubuntu", "linuxmint", "pop", "elementary") || idLike.contains("debian") || idLike.contains("ubuntu") ) { Logger.d { "Detected Debian-based distribution: $id" } return LinuxPackageType.DEB } if (id in listOf( "fedora", "rhel", "centos", "rocky", "almalinux", "opensuse", "suse", ) || idLike.contains("fedora") || idLike.contains("rhel") || idLike.contains("suse") || idLike.contains("centos") ) { Logger.d { "Detected RPM-based distribution: $id" } return LinuxPackageType.RPM } } if (commandExists("apt") || commandExists("apt-get")) { Logger.d { "Detected package manager: apt" } return LinuxPackageType.DEB } if (commandExists("dnf")) { Logger.d { "Detected package manager: dnf" } return LinuxPackageType.RPM } if (commandExists("yum")) { Logger.d { "Detected package manager: yum" } return LinuxPackageType.RPM } if (commandExists("zypper")) { Logger.d { "Detected package manager: zypper" } return LinuxPackageType.RPM } Logger.d { "Could not determine package type, defaulting to UNIVERSAL" } LinuxPackageType.UNIVERSAL } catch (e: Exception) { Logger.w { "Failed to detect Linux package type: ${e.message}" } LinuxPackageType.UNIVERSAL } } private fun tryReadOsRelease(): Map? { val osReleaseFiles = listOf( "/etc/os-release", "/usr/lib/os-release", ) for (filePath in osReleaseFiles) { try { val file = File(filePath) if (file.exists()) { val content = file.readText() return parseOsRelease(content) } } catch (e: Exception) { Logger.w { "Could not read $filePath: ${e.message}" } } } return null } private fun parseOsRelease(content: String): Map { val result = mutableMapOf() content.lines().forEach { line -> val trimmed = line.trim() if (trimmed.isNotEmpty() && !trimmed.startsWith("#")) { val parts = trimmed.split("=", limit = 2) if (parts.size == 2) { val key = parts[0].trim() val value = parts[1].trim().removeSurrounding("\"") result[key] = value } } } return result } private fun commandExists(command: String): Boolean = try { val process = ProcessBuilder("which", command).start() process.waitFor() == 0 } catch (_: Exception) { false } private fun isArchitectureCompatible( assetName: String, systemArch: SystemArchitecture, ): Boolean { val name = assetName.lowercase() if (platform == Platform.MACOS) { if (name.contains("universal") || name.contains("darwin")) { return true } if ((name.endsWith(".dmg") || name.endsWith(".pkg")) && !listOf("x86_64", "amd64", "arm64", "aarch64").any { name.contains(it) } ) { return true } } return AssetArchitectureMatcher.isCompatible(name, systemArch) } private fun isExactArchitectureMatch( assetName: String, systemArch: SystemArchitecture, ): Boolean = AssetArchitectureMatcher.isExactMatch(assetName, systemArch) override suspend fun isSupported(extOrMime: String): Boolean { val ext = extOrMime.lowercase().removePrefix(".") return when (platform) { Platform.WINDOWS -> ext in listOf("msi", "exe") Platform.MACOS -> ext in listOf("dmg", "pkg") Platform.LINUX -> ext in listOf("appimage", "deb", "rpm") else -> false } } override suspend fun ensurePermissionsOrThrow(extOrMime: String) = withContext(Dispatchers.IO) { val ext = extOrMime.lowercase().removePrefix(".") if (platform == Platform.LINUX && ext == "appimage") { try { val tempFile = File.createTempFile("appimage_perm_test", ".tmp") try { val canSetExecutable = tempFile.setExecutable(true) if (!canSetExecutable) { throw IllegalStateException( "Unable to set executable permissions. AppImage installation requires " + "the ability to make files executable.", ) } } finally { tempFile.delete() } } catch (e: IOException) { throw IllegalStateException( "Failed to verify permission capabilities for AppImage installation: ${e.message}", e, ) } catch (e: SecurityException) { throw IllegalStateException( "Security restrictions prevent setting executable permissions for AppImage files.", e, ) } } } override fun uninstall(packageName: String) { // Desktop doesn't have a unified uninstall mechanism Logger.d { "Uninstall not supported on desktop for: $packageName" } } override suspend fun install( filePath: String, extOrMime: String, ) = withContext(Dispatchers.IO) { val file = File(filePath) if (!file.exists()) { throw IllegalStateException("File not found: $filePath") } val ext = extOrMime.lowercase().removePrefix(".") when (platform) { Platform.WINDOWS -> installWindows(file, ext) Platform.MACOS -> installMacOS(file, ext) Platform.LINUX -> installLinux(file, ext) else -> throw UnsupportedOperationException("Installation not supported on $platform") } } private fun installWindows( file: File, ext: String, ) { when (ext) { "msi" -> { val pb = ProcessBuilder("msiexec", "/i", file.absolutePath) pb.start() } "exe" -> { if (Desktop.isDesktopSupported()) { Desktop.getDesktop().open(file) } else { val pb = ProcessBuilder(file.absolutePath) pb.start() } } else -> { throw IllegalArgumentException("Unsupported Windows installer: .$ext") } } } private fun installMacOS( file: File, ext: String, ) { when (ext) { "dmg" -> { val pb = ProcessBuilder("open", file.absolutePath) pb.start() tryShowNotification( title = "Installation Started", message = "Please drag the application to your Applications folder", ) } "pkg" -> { try { val script = """ do shell script "installer -pkg '${file.absolutePath}' -target /" with administrator privileges """.trimIndent() val pb = ProcessBuilder("osascript", "-e", script) val process = pb.start() val exitCode = process.waitFor() if (exitCode != 0) { throw IOException("Installation cancelled or failed") } Logger.d { "PKG installed successfully" } } catch (e: Exception) { Logger.w { "Automated install failed, opening installer GUI: ${e.message}" } ProcessBuilder("open", file.absolutePath).start() } } else -> { throw IllegalArgumentException("Unsupported macOS installer: .$ext") } } } private fun tryShowNotification( title: String, message: String, ) { if (platform == Platform.MACOS) { try { val script = """ display notification "$message" with title "$title" """.trimIndent() ProcessBuilder("osascript", "-e", script).start() } catch (e: Exception) { Logger.w { "Could not show macOS notification: ${e.message}" } } } else { try { ProcessBuilder( "notify-send", title, message, "-u", "critical", "-t", "10000", ).start() } catch (e: Exception) { Logger.w { "Could not show notification: ${e.message}" } } } } private fun installLinux( file: File, ext: String, ) { when (ext) { "appimage" -> { installAppImage(file) } "deb" -> { installDebPackage(file) } "rpm" -> { installRpmPackage(file) } else -> { throw IllegalArgumentException("Unsupported Linux installer: .$ext") } } } private fun installDebPackage(file: File) { Logger.d { "Installing DEB package: ${file.absolutePath}" } if (linuxPackageType == LinuxPackageType.RPM) { Logger.i { "Detected DEB package on RPM system. Initiating conversion flow." } openTerminalForAlienConversion(file.absolutePath) return } val installMethods = listOf( listOf("pkexec", "apt", "install", "-y", file.absolutePath), listOf("pkexec", "sh", "-c", "dpkg -i '${file.absolutePath}' || apt-get install -f -y"), listOf("gdebi-gtk", file.absolutePath), null, ) for (method in installMethods) { if (method == null) { openTerminalForDebInstall(file.absolutePath) return } try { Logger.d { "Trying installation method: ${method.joinToString(" ")}" } val process = ProcessBuilder(method).start() val exitCode = process.waitFor() if (exitCode == 0) { Logger.d { "DEB package installed successfully" } tryShowNotification("Installation Complete", "Package installed successfully") return } else { Logger.w { "Installation method failed with exit code: $exitCode" } } } catch (e: IOException) { Logger.w { "Installation method not available: ${e.message}" } } } throw IOException("Could not install DEB package. Please install it manually.") } private fun openTerminalForAlienConversion(filePath: String) { Logger.d { "Opening terminal for Alien conversion and installation" } val availableTerminals = detectAvailableTerminals() if (availableTerminals.isEmpty()) { Logger.e { "No terminal emulator found for conversion" } tryShowNotification( "Conversion Required", "Please install 'alien', convert '$filePath' to RPM, and install manually.", ) throw IOException("No terminal found to run Alien conversion.") } val command = buildString { append("echo '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'; ") append("echo 'DEB Package on RPM System Detected'; ") append("echo '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'; ") append("echo ''; ") append("echo 'This package will be converted to RPM format.'; ") append("echo 'This requires the \"alien\" tool.'; ") append("echo ''; ") append("if ! command -v alien &> /dev/null; then ") append("echo 'Installing alien and rpm-build...'; ") append("sudo dnf install -y alien rpm-build 2>/dev/null || ") append("sudo yum install -y alien rpm-build 2>/dev/null || ") append("sudo zypper install -y alien rpm-build 2>/dev/null; ") append("fi; ") append("if ! command -v alien &> /dev/null; then ") append("echo ''; ") append("echo 'ERROR: Failed to install alien.'; ") append("echo 'Please install it manually: sudo dnf install alien rpm-build'; ") append("echo ''; ") append("echo 'Press Enter to close...'; read; exit 1; ") append("fi; ") append("echo ''; ") append("echo 'Converting to RPM (this may take a minute)...'; ") append("TMPDIR=/tmp/alien_install_$; ") append($$"mkdir -p \"$TMPDIR\" && cd \"$TMPDIR\" || exit 1; ") append("cp '$filePath' ./package.deb; ") append("sudo alien -r -c package.deb; ") append("if [ ! -f *.rpm ]; then ") append("echo ''; ") append("echo 'ERROR: Conversion failed.'; ") append($$"cd .. && rm -rf \"$TMPDIR\"; ") append("echo 'Press Enter to close...'; read; exit 1; ") append("fi; ") append("echo ''; ") append("echo 'Installing converted RPM...'; ") append("INSTALL_SUCCESS=0; ") append("if sudo dnf install -y ./*.rpm 2>&1; then INSTALL_SUCCESS=1; ") append("elif sudo yum install -y ./*.rpm 2>&1; then INSTALL_SUCCESS=1; ") append("elif sudo zypper install -y --allow-unsigned-rpm ./*.rpm 2>&1; then INSTALL_SUCCESS=1; ") append("elif sudo rpm -ivh --nodeps --force ./*.rpm 2>&1; then INSTALL_SUCCESS=1; ") append("fi; ") append("echo ''; ") append($$"if [ $INSTALL_SUCCESS -eq 1 ]; then ") append("echo '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'; ") append("echo 'Installation Complete!'; ") append("echo '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'; ") append("else ") append("echo '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'; ") append("echo 'Installation Failed!'; ") append("echo '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'; ") append("echo ''; ") append("echo 'The RPM was created but installation failed.'; ") append("echo 'This usually happens due to file conflicts.'; ") append("echo ''; ") append("echo 'The converted RPM is located at:'; ") append($$"echo \"$TMPDIR/\"*.rpm; ") append("echo ''; ") append("echo 'You can try installing it manually with:'; ") append($$"echo \"sudo rpm -ivh --force $TMPDIR/\"*.rpm; ") append("echo ''; ") append("echo 'Or open the file with your software manager.'; ") append("fi; ") append($$"cd .. && rm -rf \"$TMPDIR\"; ") append("echo ''; ") append("echo 'Press Enter to close...'; read") } runCommandInTerminal(command, availableTerminals) } private fun installRpmPackage(file: File) { Logger.d { "Installing RPM package: ${file.absolutePath}" } val installMethods = listOf( listOf("pkexec", "dnf", "install", "-y", "--nogpgcheck", file.absolutePath), listOf("pkexec", "yum", "install", "-y", "--nogpgcheck", file.absolutePath), listOf("pkexec", "zypper", "install", "-y", "--no-gpg-checks", file.absolutePath), listOf("pkexec", "rpm", "-ivh", "--nosignature", file.absolutePath), null, ) for (method in installMethods) { if (method == null) { openTerminalForRpmInstall(file.absolutePath) return } try { Logger.d { "Trying installation method: ${method.joinToString(" ")}" } val process = ProcessBuilder(method).start() val exitCode = process.waitFor() if (exitCode == 0) { Logger.d { "RPM package installed successfully" } tryShowNotification("Installation Complete", "Package installed successfully") return } else { Logger.w { "Installation method failed with exit code: $exitCode" } } } catch (e: IOException) { Logger.w { "Installation method not available: ${e.message}" } } } throw IOException("Could not install RPM package. Please install it manually.") } private fun openTerminalForDebInstall(filePath: String) { val command = buildString { append("echo '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'; ") append("echo 'Installing DEB Package'; ") append("echo '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'; ") append("echo ''; ") append("sudo dpkg -i '$filePath' && sudo apt-get install -f -y; ") append("echo ''; ") append("echo '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'; ") append("echo 'Installation Complete!'; ") append("echo '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'; ") append("echo ''; ") append("echo 'Press Enter to close...'; read") } val availableTerminals = detectAvailableTerminals() if (availableTerminals.isEmpty()) { tryShowNotification( "Installation Required", "Please install manually using your file manager", ) tryCopyToClipboard("sudo dpkg -i '$filePath' && sudo apt-get install -f -y") throw IOException("No terminal emulator found.") } runCommandInTerminal(command, availableTerminals) } private fun openTerminalForRpmInstall(filePath: String) { val command = buildString { append("echo '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'; ") append("echo 'Installing RPM Package'; ") append("echo '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'; ") append("echo ''; ") append("sudo dnf install -y --nogpgcheck '$filePath' 2>/dev/null || ") append("sudo yum install -y --nogpgcheck '$filePath' 2>/dev/null || ") append("sudo zypper install -y --no-gpg-checks '$filePath' 2>/dev/null || ") append("sudo rpm -ivh --nosignature '$filePath'; ") append("echo ''; ") append("echo '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'; ") append("echo 'Installation Complete!'; ") append("echo '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'; ") append("echo ''; ") append("echo 'Press Enter to close...'; read") } val availableTerminals = detectAvailableTerminals() if (availableTerminals.isEmpty()) { tryShowNotification( "Installation Required", "Please install manually using your file manager", ) tryCopyToClipboard("sudo dnf install -y --nogpgcheck '$filePath'") throw IOException("No terminal emulator found.") } runCommandInTerminal(command, availableTerminals) } private fun runCommandInTerminal( command: String, terminals: List, ) { for (terminal in terminals) { try { Logger.d { "Trying terminal: ${terminal.name}" } val processBuilder = when (terminal) { LinuxTerminal.GNOME_TERMINAL -> { ProcessBuilder( "gnome-terminal", "--", "bash", "-c", command, ) } LinuxTerminal.KONSOLE -> { ProcessBuilder( "konsole", "-e", "bash", "-c", command, ) } LinuxTerminal.XTERM -> { ProcessBuilder( "xterm", "-e", "bash", "-c", command, ) } LinuxTerminal.XFCE4_TERMINAL -> { ProcessBuilder( "xfce4-terminal", "-e", "bash -c \"$command\"", ) } LinuxTerminal.ALACRITTY -> { ProcessBuilder( "alacritty", "-e", "bash", "-c", command, ) } LinuxTerminal.KITTY -> { ProcessBuilder( "kitty", "bash", "-c", command, ) } LinuxTerminal.TILIX -> { ProcessBuilder( "tilix", "-e", "bash -c \"$command\"", ) } LinuxTerminal.MATE_TERMINAL -> { ProcessBuilder( "mate-terminal", "-e", "bash -c \"$command\"", ) } } processBuilder.start() Logger.d { "Terminal opened successfully: ${terminal.name}" } return } catch (e: IOException) { Logger.w { "Failed to open ${terminal.name}: ${e.message}" } } } throw IOException("Could not open any terminal emulator") } private fun detectAvailableTerminals(): List { val availableTerminals = mutableListOf() val terminalCommands = mapOf( LinuxTerminal.GNOME_TERMINAL to "gnome-terminal", LinuxTerminal.KONSOLE to "konsole", LinuxTerminal.XFCE4_TERMINAL to "xfce4-terminal", LinuxTerminal.ALACRITTY to "alacritty", LinuxTerminal.KITTY to "kitty", LinuxTerminal.TILIX to "tilix", LinuxTerminal.MATE_TERMINAL to "mate-terminal", LinuxTerminal.XTERM to "xterm", ) for ((terminal, command) in terminalCommands) { if (commandExists(command)) { availableTerminals.add(terminal) Logger.d { "Found terminal: $command" } } } return availableTerminals } private fun tryCopyToClipboard(text: String) { try { val clipboard = Toolkit.getDefaultToolkit().systemClipboard clipboard.setContents(StringSelection(text), null) Logger.d { "Command copied to clipboard" } } catch (e: Exception) { Logger.w { "Could not copy to clipboard: ${e.message}" } } } private fun installAppImage(file: File) { Logger.d { "Installing AppImage: ${file.absolutePath}" } try { Logger.d { "Moving AppImage to ~/Applications..." } val installedFile = moveToApplicationsDirectory(file) Logger.d { "Moved to: ${installedFile.absolutePath}" } Logger.d { "Setting executable permissions..." } val executableSet = installedFile.setExecutable(true, false) Logger.d { "Set executable via Java: $executableSet" } if (!executableSet) { Logger.w { "Failed to set executable via Java, trying chmod..." } val chmodProcess = ProcessBuilder("chmod", "+x", installedFile.absolutePath).start() val chmodExitCode = chmodProcess.waitFor() Logger.d { "chmod exit code: $chmodExitCode" } } if (!installedFile.canExecute()) { throw IllegalStateException("Failed to make AppImage executable") } Logger.d { "AppImage is now executable" } Logger.d { "Launching AppImage..." } val process = ProcessBuilder(installedFile.absolutePath) .inheritIO() .start() Logger.d { "AppImage launched successfully (PID: ${process.pid()})" } showInstallationNotification(installedFile) Logger.d { "AppImage installation completed successfully" } } catch (e: IOException) { Logger.e { "Failed to install AppImage: ${e.message}" } e.printStackTrace() throw IllegalStateException( "Failed to install AppImage: ${e.message}. " + "Please ensure you have write permissions to ~/Applications folder.", e, ) } catch (e: SecurityException) { Logger.e { "Security exception: ${e.message}" } e.printStackTrace() throw IllegalStateException( "Security restrictions prevent installing AppImage.", e, ) } catch (e: Exception) { Logger.e { "Unexpected error: ${e.message}" } e.printStackTrace() throw IllegalStateException("Failed to install AppImage: ${e.message}", e) } } /** * Move AppImage to ~/Applications directory * Creates the directory if it doesn't exist */ private fun moveToApplicationsDirectory(file: File): File { val homeDir = System.getProperty("user.home") val applicationsDir = File(homeDir, "Applications") if (!applicationsDir.exists()) { Logger.d { "Creating ~/Applications directory..." } val created = applicationsDir.mkdirs() Logger.d { "Directory created: $created" } } if (file.parent == applicationsDir.absolutePath) { Logger.d { "AppImage already in ~/Applications, no move needed" } return file } val destinationFile = File(applicationsDir, file.name) val finalDestination = if (destinationFile.exists()) { Logger.d { "File already exists in ~/Applications, generating unique name" } generateUniqueFileName(applicationsDir, file.name) } else { destinationFile } Logger.d { "Moving from: ${file.absolutePath}" } Logger.d { "Moving to: ${finalDestination.absolutePath}" } file.copyTo(finalDestination, overwrite = false) Logger.d { "Copy successful, file size: ${finalDestination.length()} bytes" } val deleted = file.delete() Logger.d { "Original file deleted: $deleted" } if (!finalDestination.exists()) { throw IllegalStateException("File was moved but doesn't exist at destination") } return finalDestination } private fun showInstallationNotification(file: File) { try { val message = "AppImage installed and launched from ~/Applications" Logger.i { message } Logger.i { "Location: ${file.absolutePath}" } ProcessBuilder( "notify-send", "-i", "application-x-executable", "AppImage Installed", "Installed to ~/Applications\n\nYou can find it at:\n${file.name}", ).start() } catch (e: Exception) { Logger.d { "Could not show notification: ${e.message}" } } } private fun generateUniqueFileName( directory: File, originalName: String, ): File { val nameWithoutExtension = originalName.substringBeforeLast(".") val extension = originalName.substringAfterLast(".", "") var counter = 1 var candidateFile: File do { val newName = if (extension.isNotEmpty()) { "${nameWithoutExtension}_$counter.$extension" } else { "${nameWithoutExtension}_$counter" } candidateFile = File(directory, newName) counter++ } while (candidateFile.exists() && counter < 1000) if (candidateFile.exists()) { throw IllegalStateException("Could not generate unique filename") } return candidateFile } } ================================================ FILE: core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopInstallerInfoExtractor.kt ================================================ package zed.rainxch.core.data.services import zed.rainxch.core.domain.model.ApkPackageInfo import zed.rainxch.core.domain.system.InstallerInfoExtractor class DesktopInstallerInfoExtractor : InstallerInfoExtractor { override suspend fun extractPackageInfo(filePath: String): ApkPackageInfo? = null } ================================================ FILE: core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopInstallerStatusProvider.kt ================================================ package zed.rainxch.core.data.services import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import zed.rainxch.core.domain.model.ShizukuAvailability import zed.rainxch.core.domain.system.InstallerStatusProvider /** * Desktop (JVM) no-op implementation of [InstallerStatusProvider]. * Shizuku is Android-only, so this always reports UNAVAILABLE. */ class DesktopInstallerStatusProvider : InstallerStatusProvider { override val shizukuAvailability: StateFlow = MutableStateFlow(ShizukuAvailability.UNAVAILABLE).asStateFlow() override fun requestShizukuPermission() { // No-op on desktop } } ================================================ FILE: core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopLocalizationManager.kt ================================================ package zed.rainxch.core.data.services import java.util.Locale class DesktopLocalizationManager : LocalizationManager { override fun getCurrentLanguageCode(): String { val locale = Locale.getDefault() val language = locale.language val country = locale.country return if (country.isNotEmpty()) { "$language-$country" } else { language } } override fun getPrimaryLanguageCode(): String = Locale.getDefault().language } ================================================ FILE: core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopPackageMonitor.kt ================================================ package zed.rainxch.core.data.services import zed.rainxch.core.domain.model.DeviceApp import zed.rainxch.core.domain.model.SystemPackageInfo import zed.rainxch.core.domain.system.PackageMonitor class DesktopPackageMonitor : PackageMonitor { override suspend fun isPackageInstalled(packageName: String): Boolean = false override suspend fun getInstalledPackageInfo(packageName: String): SystemPackageInfo? = null override suspend fun getAllInstalledPackageNames(): Set = setOf() override suspend fun getAllInstalledApps(): List = emptyList() } ================================================ FILE: core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopUpdateScheduleManager.kt ================================================ package zed.rainxch.core.data.services import zed.rainxch.core.domain.system.UpdateScheduleManager /** * No-op implementation for Desktop — WorkManager is Android-only. */ class DesktopUpdateScheduleManager : UpdateScheduleManager { override fun reschedule(intervalHours: Long) { // No background scheduler on Desktop } } ================================================ FILE: core/data/src/jvmMain/kotlin/zed/rainxch/core/data/utils/DesktopAppLauncher.kt ================================================ package zed.rainxch.core.data.utils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import zed.rainxch.core.domain.logging.GitHubStoreLogger import zed.rainxch.core.domain.model.InstalledApp import zed.rainxch.core.domain.model.Platform import zed.rainxch.core.domain.utils.AppLauncher import java.io.File class DesktopAppLauncher( private val logger: GitHubStoreLogger, private val platform: Platform, ) : AppLauncher { override suspend fun launchApp(installedApp: InstalledApp): Result = withContext(Dispatchers.IO) { runCatching { when (platform) { Platform.WINDOWS -> launchWindowsApp(installedApp) Platform.MACOS -> launchMacOSApp(installedApp) Platform.LINUX -> launchLinuxApp(installedApp) else -> throw Exception("Unsupported platform: $platform") } }.onFailure { error -> logger.error("Failed to launch app ${installedApp.appName}: ${error.message}") } } override suspend fun canLaunchApp(installedApp: InstalledApp): Boolean = withContext(Dispatchers.IO) { when (platform) { Platform.WINDOWS -> canLaunchWindowsApp(installedApp) Platform.MACOS -> canLaunchMacOSApp(installedApp) Platform.LINUX -> canLaunchLinuxApp(installedApp) else -> false } } private fun launchWindowsApp(installedApp: InstalledApp) { val programFiles = listOfNotNull( System.getenv("ProgramFiles"), System.getenv("ProgramFiles(x86)"), System.getenv("LOCALAPPDATA"), ) var launched = false for (basePath in programFiles) { val possiblePaths = listOf( File(basePath, installedApp.appName), File(basePath, installedApp.repoName), File(basePath, installedApp.packageName.substringAfterLast(".")), ) for (appDir in possiblePaths) { if (appDir.exists() && appDir.isDirectory) { val exeFiles = appDir .walkTopDown() .maxDepth(3) .filter { it.extension.equals("exe", ignoreCase = true) } .filter { !it.name.contains("uninstall", ignoreCase = true) } .toList() val mainExe = exeFiles.find { it.nameWithoutExtension.equals(installedApp.appName, ignoreCase = true) || it.nameWithoutExtension.equals( installedApp.repoName, ignoreCase = true, ) } ?: exeFiles.firstOrNull() if (mainExe != null) { ProcessBuilder("cmd", "/c", "start", "", mainExe.absolutePath) .start() launched = true logger.debug("Launched Windows app from: ${mainExe.absolutePath}") break } } } if (launched) break } if (!launched) { val appNameVariations = listOf( installedApp.appName, installedApp.repoName, installedApp.appName.replace(" ", ""), ) for (name in appNameVariations) { try { ProcessBuilder("cmd", "/c", "start", "", name) .start() launched = true logger.debug("Launched Windows app using shell: $name") break } catch (e: Exception) { } } } if (!launched) { val displayName = findWindowsDisplayName(installedApp) if (displayName != null) { val installLocation = getWindowsInstallLocation(displayName) if (installLocation != null) { val installDir = File(installLocation) val exeFiles = installDir.listFiles { file -> file.extension.equals("exe", ignoreCase = true) && !file.name.contains("uninstall", ignoreCase = true) } val mainExe = exeFiles?.firstOrNull() if (mainExe != null) { ProcessBuilder("cmd", "/c", "start", "", mainExe.absolutePath) .start() launched = true logger.debug("Launched Windows app from registry location: ${mainExe.absolutePath}") } } } } if (!launched) { throw Exception("Could not find executable for ${installedApp.appName}") } } private fun launchMacOSApp(installedApp: InstalledApp) { val appName = if (installedApp.appName.endsWith(".app")) { installedApp.appName } else { "${installedApp.appName}.app" } val appPath = File("/Applications", appName) if (appPath.exists()) { ProcessBuilder("open", "-a", appPath.absolutePath).start() logger.debug("Launched macOS app: ${appPath.absolutePath}") return } val appsDir = File("/Applications") val matchingApp = appsDir.listFiles()?.find { file -> file.isDirectory && file.name.endsWith(".app") && ( file.name.contains(installedApp.appName, ignoreCase = true) || file.name.contains(installedApp.repoName, ignoreCase = true) ) } if (matchingApp != null) { ProcessBuilder("open", "-a", matchingApp.absolutePath).start() logger.debug("Launched macOS app: ${matchingApp.absolutePath}") return } try { ProcessBuilder("open", "-b", installedApp.packageName).start() logger.debug("Launched macOS app by bundle ID: ${installedApp.packageName}") return } catch (e: Exception) { } val userAppsDir = File(System.getProperty("user.home"), "Applications") val userMatchingApp = userAppsDir.listFiles()?.find { file -> file.isDirectory && file.name.endsWith(".app") && ( file.name.contains(installedApp.appName, ignoreCase = true) || file.name.contains(installedApp.repoName, ignoreCase = true) ) } if (userMatchingApp != null) { ProcessBuilder("open", "-a", userMatchingApp.absolutePath).start() logger.debug("Launched macOS app from user folder: ${userMatchingApp.absolutePath}") return } throw Exception("Could not find app for ${installedApp.appName}") } private fun launchLinuxApp(installedApp: InstalledApp) { val commandVariations = listOf( installedApp.appName.lowercase().replace(" ", "-"), installedApp.appName.lowercase().replace(" ", ""), installedApp.repoName.lowercase(), installedApp.packageName.substringAfterLast("."), ) for (command in commandVariations) { try { ProcessBuilder(command).start() logger.debug("Launched Linux app with command: $command") return } catch (e: Exception) { } } val desktopFileDirs = listOf( "/usr/share/applications", "/usr/local/share/applications", "${System.getProperty("user.home")}/.local/share/applications", ) for (dir in desktopFileDirs) { val desktopDir = File(dir) if (!desktopDir.exists()) continue val desktopFile = desktopDir.listFiles()?.find { file -> file.extension.equals("desktop", ignoreCase = true) && ( file.nameWithoutExtension.contains( installedApp.appName, ignoreCase = true, ) || file.nameWithoutExtension.contains( installedApp.repoName, ignoreCase = true, ) ) } if (desktopFile != null) { val execLine = desktopFile .readLines() .find { it.trim().startsWith("Exec=") }?.substringAfter("Exec=") if (execLine != null) { val command = execLine.replace(Regex("%[fFuU]"), "").trim() ProcessBuilder("sh", "-c", command).start() logger.debug("Launched Linux app from .desktop file: $command") return } } } try { val flatpakList = executeCommand("flatpak", "list", "--app") if (flatpakList.contains(installedApp.appName, ignoreCase = true) || flatpakList.contains(installedApp.repoName, ignoreCase = true) ) { val appId = flatpakList .lines() .find { it.contains(installedApp.appName, ignoreCase = true) || it.contains(installedApp.repoName, ignoreCase = true) }?.split(Regex("\\s+")) ?.firstOrNull() if (appId != null) { ProcessBuilder("flatpak", "run", appId).start() logger.debug("Launched Linux app via flatpak: $appId") return } } } catch (e: Exception) { } if (installedApp.fileExtension.equals("appimage", ignoreCase = true)) { val appImageDirs = listOf( "${System.getProperty("user.home")}/Applications", "${System.getProperty("user.home")}/.local/bin", "/opt", ) for (dir in appImageDirs) { val dirFile = File(dir) val appImage = dirFile.listFiles()?.find { file -> file.extension.equals("appimage", ignoreCase = true) && file.nameWithoutExtension.contains( installedApp.appName, ignoreCase = true, ) } if (appImage != null && appImage.canExecute()) { ProcessBuilder(appImage.absolutePath).start() logger.debug("Launched AppImage: ${appImage.absolutePath}") return } } } throw Exception("Could not launch ${installedApp.appName}") } private fun canLaunchWindowsApp(installedApp: InstalledApp): Boolean { val programFiles = listOfNotNull( System.getenv("ProgramFiles"), System.getenv("ProgramFiles(x86)"), ) for (basePath in programFiles) { val appDir = File(basePath, installedApp.appName) if (appDir.exists() && appDir.isDirectory) { val hasExe = appDir .walkTopDown() .maxDepth(3) .any { it.extension.equals("exe", ignoreCase = true) } if (hasExe) return true } } return false } private fun canLaunchMacOSApp(installedApp: InstalledApp): Boolean { val appPath = File("/Applications", "${installedApp.appName}.app") if (appPath.exists()) return true val appsDir = File("/Applications") return appsDir.listFiles()?.any { file -> file.name.endsWith(".app") && file.name.contains(installedApp.appName, ignoreCase = true) } ?: false } private fun canLaunchLinuxApp(installedApp: InstalledApp): Boolean { val command = installedApp.appName.lowercase().replace(" ", "-") val which = executeCommand("which", command) if (which.isNotBlank()) return true val desktopFileDirs = listOf( "/usr/share/applications", "/usr/local/share/applications", "${System.getProperty("user.home")}/.local/share/applications", ) return desktopFileDirs.any { dir -> File(dir).listFiles()?.any { file -> file.extension.equals("desktop", ignoreCase = true) && file.nameWithoutExtension.contains(installedApp.appName, ignoreCase = true) } ?: false } } private fun findWindowsDisplayName(installedApp: InstalledApp): String? { val searchName = installedApp.appName val result = executeCommand( "powershell", "-Command", "Get-ItemProperty HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\* | Where-Object { \$_.DisplayName -like '*$searchName*' } | Select-Object -First 1 -ExpandProperty DisplayName", ) return result.trim().takeIf { it.isNotBlank() } } private fun getWindowsInstallLocation(displayName: String): String? { val result = executeCommand( "powershell", "-Command", "Get-ItemProperty HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\* | Where-Object { \$_.DisplayName -eq '$displayName' } | Select-Object -First 1 -ExpandProperty InstallLocation", ) return result.trim().takeIf { it.isNotBlank() } } private fun executeCommand(vararg command: String): String = try { val process = ProcessBuilder(*command) .redirectErrorStream(true) .start() val output = process.inputStream.bufferedReader().readText() process.waitFor() output } catch (e: Exception) { "" } } ================================================ FILE: core/data/src/jvmMain/kotlin/zed/rainxch/core/data/utils/DesktopBrowserHelper.kt ================================================ package zed.rainxch.core.data.utils import zed.rainxch.core.domain.utils.BrowserHelper import java.awt.Desktop import java.net.URI class DesktopBrowserHelper : BrowserHelper { override fun openUrl( url: String, onFailure: (error: String) -> Unit, ) { val os = System.getProperty("os.name").lowercase() try { when { os.contains("linux") -> { val processBuilder = ProcessBuilder("xdg-open", url) processBuilder.redirectErrorStream(true) processBuilder.start() } Desktop.isDesktopSupported() && Desktop .getDesktop() .isSupported(Desktop.Action.BROWSE) -> { Desktop.getDesktop().browse(URI(url)) } else -> { onFailure("Cannot open browser automatically. Please visit: $url") } } } catch (e: Exception) { onFailure("Failed to open browser: ${e.message}. Please visit: $url") } } } ================================================ FILE: core/data/src/jvmMain/kotlin/zed/rainxch/core/data/utils/DesktopClipboardHelper.kt ================================================ package zed.rainxch.core.data.utils import zed.rainxch.core.domain.utils.ClipboardHelper import java.awt.Toolkit import java.awt.datatransfer.DataFlavor import java.awt.datatransfer.StringSelection class DesktopClipboardHelper : ClipboardHelper { override fun copy( label: String, text: String, ) { val clipboard = Toolkit.getDefaultToolkit().systemClipboard clipboard.setContents(StringSelection(text), null) } override fun getText(): String? = try { val clipboard = Toolkit.getDefaultToolkit().systemClipboard if (clipboard.isDataFlavorAvailable(DataFlavor.stringFlavor)) { clipboard.getData(DataFlavor.stringFlavor) as? String } else { null } } catch (_: Exception) { null } } ================================================ FILE: core/data/src/jvmMain/kotlin/zed/rainxch/core/data/utils/DesktopShareManager.kt ================================================ package zed.rainxch.core.data.utils import zed.rainxch.core.domain.utils.ShareManager import java.awt.Desktop import java.awt.FileDialog import java.awt.Frame import java.awt.Toolkit import java.awt.datatransfer.StringSelection import java.io.File import javax.swing.JFileChooser import javax.swing.SwingUtilities import javax.swing.filechooser.FileNameExtensionFilter class DesktopShareManager : ShareManager { override fun shareText(text: String) { val clipboard = Toolkit.getDefaultToolkit().systemClipboard val selection = StringSelection(text) clipboard.setContents(selection, null) } override fun shareFile(fileName: String, content: String, mimeType: String) { SwingUtilities.invokeLater { val chooser = JFileChooser().apply { dialogTitle = "Save exported apps" selectedFile = File(fileName) fileFilter = FileNameExtensionFilter("JSON files", "json") } val result = chooser.showSaveDialog(null) if (result == JFileChooser.APPROVE_OPTION) { var file = chooser.selectedFile if (!file.name.endsWith(".json")) { file = File(file.absolutePath + ".json") } file.writeText(content) } } } override fun pickFile(mimeType: String, onResult: (String?) -> Unit) { SwingUtilities.invokeLater { val chooser = JFileChooser().apply { dialogTitle = "Select file to import" fileFilter = FileNameExtensionFilter("JSON files", "json") } val result = chooser.showOpenDialog(null) if (result == JFileChooser.APPROVE_OPTION) { try { val content = chooser.selectedFile.readText() onResult(content) } catch (e: Exception) { onResult(null) } } else { onResult(null) } } } } ================================================ FILE: core/domain/.gitignore ================================================ /build ================================================ FILE: core/domain/build.gradle.kts ================================================ plugins { alias(libs.plugins.convention.kmp.library) } kotlin { sourceSets { commonMain { dependencies { implementation(libs.kotlin.stdlib) implementation(libs.kotlinx.coroutines.core) } } androidMain { dependencies { } } jvmMain { dependencies { } } } } ================================================ FILE: core/domain/src/androidMain/AndroidManifest.xml ================================================ ================================================ FILE: core/domain/src/androidMain/kotlin/zed/rainxch/core/domain/Platform.android.kt ================================================ package zed.rainxch.core.domain import zed.rainxch.core.domain.model.Platform actual fun getPlatform(): Platform = Platform.ANDROID ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/Platform.kt ================================================ package zed.rainxch.core.domain import zed.rainxch.core.domain.model.Platform expect fun getPlatform(): Platform ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/logging/GitHubStoreLogger.kt ================================================ package zed.rainxch.core.domain.logging interface GitHubStoreLogger { fun debug(message: String) fun info(message: String) fun warn(message: String) fun error( message: String, throwable: Throwable? = null, ) } ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/ApkPackageInfo.kt ================================================ package zed.rainxch.core.domain.model data class ApkPackageInfo( val packageName: String, val versionName: String, val versionCode: Long, val appName: String, val signingFingerprint: String?, ) ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/AppTheme.kt ================================================ package zed.rainxch.core.domain.model enum class AppTheme { DYNAMIC, OCEAN, PURPLE, FOREST, SLATE, AMBER, ; companion object { fun fromName(name: String?): AppTheme = entries.find { it.name == name } ?: OCEAN } } ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/AssetArchitectureMatcher.kt ================================================ package zed.rainxch.core.domain.model object AssetArchitectureMatcher { private val universalRegex = Regex( pattern = """(^|[^a-z0-9])(universal|noarch|all-arch|fat)([^a-z0-9]|$)""", ) private val x86_64Regex = Regex( pattern = """(^|[^a-z0-9])(x86[_-]64|amd64|x64)([^a-z0-9]|$)""", ) private val arm64Regex = Regex( pattern = """(^|[^a-z0-9])(aarch64|arm64|arm64-v8a|armv8a|armv8l|armv8|arm-v8|v8a)([^a-z0-9]|$)""", ) private val x86Regex = Regex( pattern = """(^|[^a-z0-9])(i386|i686|x86)([^a-z0-9]|$)""", ) private val armRegex = Regex( pattern = """(^|[^a-z0-9])(armeabi-v7a|armeabi|armv7a|armv7|arm-v7|v7a|arm)([^a-z0-9]|$)""", ) fun detectArchitecture(assetName: String): SystemArchitecture? { val name = assetName.lowercase().replace('_', '-') if (universalRegex.containsMatchIn(name)) return null if (x86_64Regex.containsMatchIn(name)) return SystemArchitecture.X86_64 if (arm64Regex.containsMatchIn(name)) return SystemArchitecture.AARCH64 if (x86Regex.containsMatchIn(name)) return SystemArchitecture.X86 if (armRegex.containsMatchIn(name)) return SystemArchitecture.ARM return null } fun isCompatible( assetName: String, systemArch: SystemArchitecture, ): Boolean { val assetArch = detectArchitecture(assetName) ?: return true return when (systemArch) { SystemArchitecture.X86_64 -> assetArch == SystemArchitecture.X86_64 || assetArch == SystemArchitecture.X86 SystemArchitecture.AARCH64 -> assetArch == SystemArchitecture.AARCH64 || assetArch == SystemArchitecture.ARM SystemArchitecture.X86 -> assetArch == SystemArchitecture.X86 SystemArchitecture.ARM -> assetArch == SystemArchitecture.ARM SystemArchitecture.UNKNOWN -> true } } fun isExactMatch( assetName: String, systemArch: SystemArchitecture, ): Boolean { val assetArch = detectArchitecture(assetName) ?: return false return assetArch == systemArch } } ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/DeviceApp.kt ================================================ package zed.rainxch.core.domain.model data class DeviceApp( val packageName: String, val appName: String, val versionName: String?, val versionCode: Long, val signingFingerprint: String?, ) ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/DiscoveryPlatform.kt ================================================ package zed.rainxch.core.domain.model enum class DiscoveryPlatform { All, Android, Macos, Windows, Linux, } ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/DownloadProgress.kt ================================================ package zed.rainxch.core.domain.model data class DownloadProgress( val bytesDownloaded: Long, val totalBytes: Long?, val percent: Int?, ) ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/ExportedApp.kt ================================================ package zed.rainxch.core.domain.model import kotlinx.serialization.Serializable @Serializable data class ExportedApp( val packageName: String, val repoOwner: String, val repoName: String, val repoUrl: String, ) @Serializable data class ExportedAppList( val version: Int = 1, val exportedAt: Long = 0L, val apps: List = emptyList(), ) ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/FavoriteRepo.kt ================================================ package zed.rainxch.core.domain.model data class FavoriteRepo( val repoId: Long, val repoName: String, val repoOwner: String, val repoOwnerAvatarUrl: String, val repoDescription: String?, val primaryLanguage: String?, val repoUrl: String, val isInstalled: Boolean = false, val installedPackageName: String? = null, val latestVersion: String?, val latestReleaseUrl: String?, val addedAt: Long, val lastSyncedAt: Long, ) ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/FontTheme.kt ================================================ package zed.rainxch.core.domain.model enum class FontTheme( val displayName: String, ) { SYSTEM("System"), CUSTOM("JetBrains Mono + Inter"), ; companion object { fun fromName(name: String?): FontTheme = entries.find { it.name == name } ?: CUSTOM } } ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/GithubAsset.kt ================================================ package zed.rainxch.core.domain.model import kotlinx.serialization.Serializable @Serializable data class GithubAsset( val id: Long, val name: String, val contentType: String, val size: Long, val downloadUrl: String, val uploader: GithubUser, ) ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/GithubDeviceStart.kt ================================================ package zed.rainxch.core.domain.model data class GithubDeviceStart( val deviceCode: String, val userCode: String, val verificationUri: String, val verificationUriComplete: String? = null, val intervalSec: Int = 5, val expiresInSec: Int, ) ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/GithubDeviceTokenError.kt ================================================ package zed.rainxch.core.domain.model data class GithubDeviceTokenError( val error: String, val errorDescription: String? = null, ) ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/GithubDeviceTokenSuccess.kt ================================================ package zed.rainxch.core.domain.model data class GithubDeviceTokenSuccess( val accessToken: String, val tokenType: String, val expiresIn: Long? = null, val scope: String? = null, val refreshToken: String? = null, val refreshTokenExpiresIn: Long? = null, ) ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/GithubRelease.kt ================================================ package zed.rainxch.core.domain.model import kotlinx.serialization.Serializable @Serializable data class GithubRelease( val id: Long, val tagName: String, val name: String?, val author: GithubUser, val publishedAt: String, val description: String?, val assets: List, val tarballUrl: String, val zipballUrl: String, val htmlUrl: String, val isPrerelease: Boolean = false, ) ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/GithubRepoSummary.kt ================================================ package zed.rainxch.core.domain.model import kotlinx.serialization.Serializable @Serializable data class GithubRepoSummary( val id: Long, val name: String, val fullName: String, val owner: GithubUser, val description: String?, val defaultBranch: String, val htmlUrl: String, val stargazersCount: Int, val forksCount: Int, val language: String?, val topics: List?, val releasesUrl: String, val updatedAt: String, val isFork: Boolean = false, val availablePlatforms: List = emptyList(), ) ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/GithubUser.kt ================================================ package zed.rainxch.core.domain.model import kotlinx.serialization.Serializable @Serializable data class GithubUser( val id: Long, val login: String, val avatarUrl: String, val htmlUrl: String, ) ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/GithubUserProfile.kt ================================================ package zed.rainxch.core.domain.model import kotlinx.serialization.Serializable @Serializable data class GithubUserProfile( val id: Long, val login: String, val name: String?, val bio: String?, val avatarUrl: String, val htmlUrl: String, val followers: Int, val following: Int, val publicRepos: Int, val location: String?, val company: String?, val blog: String?, val twitterUsername: String?, ) ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/InstallSource.kt ================================================ package zed.rainxch.core.domain.model enum class InstallSource { THIS_APP, OBTAINIUM, APP_MANAGER, MANUAL, UNKNOWN, } ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/InstalledApp.kt ================================================ package zed.rainxch.core.domain.model data class InstalledApp( val packageName: String, val repoId: Long, val repoName: String, val repoOwner: String, val repoOwnerAvatarUrl: String, val repoDescription: String?, val primaryLanguage: String?, val repoUrl: String, val installedVersion: String, val installedAssetName: String?, val installedAssetUrl: String?, val latestVersion: String?, val latestAssetName: String?, val latestAssetUrl: String?, val latestAssetSize: Long?, val appName: String, val installSource: InstallSource, val installedAt: Long, val lastCheckedAt: Long, val lastUpdatedAt: Long, val isUpdateAvailable: Boolean, val signingFingerprint: String?, val updateCheckEnabled: Boolean = true, val releaseNotes: String? = "", val systemArchitecture: String, val fileExtension: String, val isPendingInstall: Boolean = false, val installedVersionName: String? = null, val installedVersionCode: Long = 0L, val latestVersionName: String? = null, val latestVersionCode: Long? = null, ) ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/InstallerType.kt ================================================ package zed.rainxch.core.domain.model enum class InstallerType { DEFAULT, SHIZUKU; companion object { fun fromName(name: String?): InstallerType = entries.find { it.name == name } ?: DEFAULT } } ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/PackageChangeType.kt ================================================ package zed.rainxch.core.domain.model enum class PackageChangeType { INSTALLED, UNINSTALLED, UPDATED, } ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/PaginatedDiscoveryRepositories.kt ================================================ package zed.rainxch.core.domain.model import kotlinx.serialization.Serializable @Serializable data class PaginatedDiscoveryRepositories( val repos: List, val hasMore: Boolean, val nextPageIndex: Int, val totalCount: Int? = null, ) ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/Platform.kt ================================================ package zed.rainxch.core.domain.model enum class Platform { ANDROID, WINDOWS, MACOS, LINUX, } ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/ProxyConfig.kt ================================================ package zed.rainxch.core.domain.model sealed class ProxyConfig { data object None : ProxyConfig() data object System : ProxyConfig() data class Http( val host: String, val port: Int, val username: String? = null, val password: String? = null, ) : ProxyConfig() data class Socks( val host: String, val port: Int, val username: String? = null, val password: String? = null, ) : ProxyConfig() } ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/RateLimitException.kt ================================================ package zed.rainxch.core.domain.model class RateLimitException( val rateLimitInfo: RateLimitInfo, ) : Exception() ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/RateLimitInfo.kt ================================================ package zed.rainxch.core.domain.model import kotlin.time.Clock import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant data class RateLimitInfo( val limit: Int, val remaining: Int, val resetTimestamp: Long, val resource: String = "core", ) { val isExhausted: Boolean get() = remaining == 0 fun timeUntilReset(): Duration { val reset = Instant.fromEpochSeconds(resetTimestamp) return (reset - Clock.System.now()).coerceAtLeast(Duration.ZERO) } fun isCurrentlyLimited(): Boolean = isExhausted && timeUntilReset() > Duration.ZERO } ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/ShizukuAvailability.kt ================================================ package zed.rainxch.core.domain.model enum class ShizukuAvailability { UNAVAILABLE, NOT_RUNNING, PERMISSION_NEEDED, READY } ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/StarredRepository.kt ================================================ package zed.rainxch.core.domain.model data class StarredRepository( val repoId: Long, val repoName: String, val repoOwner: String, val repoOwnerAvatarUrl: String, val repoDescription: String?, val primaryLanguage: String?, val repoUrl: String, val stargazersCount: Int, val forksCount: Int, val openIssuesCount: Int, val isInstalled: Boolean = false, val installedPackageName: String? = null, val latestVersion: String?, val latestReleaseUrl: String?, val starredAt: Long?, val addedAt: Long, val lastSyncedAt: Long, ) ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/SystemArchitecture.kt ================================================ package zed.rainxch.core.domain.model enum class SystemArchitecture { X86_64, // Intel/AMD 64-bit AARCH64, // ARM 64-bit X86, // Intel/AMD 32-bit ARM, // ARM 32-bit UNKNOWN, ; companion object { fun fromString(arch: String): SystemArchitecture { val normalized = arch.lowercase().trim() return when (normalized) { in listOf("x86_64", "amd64", "x64") -> X86_64 in listOf("aarch64", "arm64", "arm64-v8a", "armv8", "armv8a", "armv8l") -> AARCH64 in listOf("x86", "i386", "i686") -> X86 in listOf("arm", "armv7l", "armv7", "armv7a", "armeabi", "armeabi-v7a") -> ARM else -> UNKNOWN } } } } ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/SystemPackageInfo.kt ================================================ package zed.rainxch.core.domain.model data class SystemPackageInfo( val packageName: String, val versionName: String, val versionCode: Long, val isInstalled: Boolean, val signingFingerprint: String?, ) ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/UpdateHistory.kt ================================================ package zed.rainxch.core.domain.model data class UpdateHistory( val id: Long = 0, val packageName: String, val appName: String, val repoOwner: String, val repoName: String, val fromVersion: String, val toVersion: String, val updatedAt: Long, val updateSource: InstallSource, val success: Boolean = true, val errorMessage: String? = null, ) ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/network/Downloader.kt ================================================ package zed.rainxch.core.domain.network import kotlinx.coroutines.flow.Flow import zed.rainxch.core.domain.model.DownloadProgress interface Downloader { fun download( url: String, suggestedFileName: String? = null, ): Flow suspend fun saveToFile( url: String, suggestedFileName: String? = null, ): String suspend fun getDownloadedFilePath(fileName: String): String? suspend fun cancelDownload(fileName: String): Boolean } ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/AuthenticationState.kt ================================================ package zed.rainxch.core.domain.repository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharedFlow interface AuthenticationState { fun isUserLoggedIn(): Flow suspend fun isCurrentlyUserLoggedIn(): Boolean val sessionExpiredEvent: SharedFlow suspend fun notifySessionExpired() } ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/FavouritesRepository.kt ================================================ package zed.rainxch.core.domain.repository import kotlinx.coroutines.flow.Flow import zed.rainxch.core.domain.model.FavoriteRepo interface FavouritesRepository { fun getAllFavorites(): Flow> fun isFavorite(repoId: Long): Flow suspend fun isFavoriteSync(repoId: Long): Boolean suspend fun toggleFavorite(repo: FavoriteRepo) suspend fun updateFavoriteInstallStatus( repoId: Long, installed: Boolean, packageName: String?, ) } ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/InstalledAppsRepository.kt ================================================ package zed.rainxch.core.domain.repository import kotlinx.coroutines.flow.Flow import zed.rainxch.core.domain.model.InstalledApp interface InstalledAppsRepository { fun getAllInstalledApps(): Flow> fun getAppsWithUpdates(): Flow> fun getUpdateCount(): Flow suspend fun getAppByPackage(packageName: String): InstalledApp? suspend fun getAppByRepoId(repoId: Long): InstalledApp? fun getAppByRepoIdAsFlow(repoId: Long): Flow suspend fun isAppInstalled(repoId: Long): Boolean suspend fun saveInstalledApp(app: InstalledApp) suspend fun deleteInstalledApp(packageName: String) suspend fun checkForUpdates(packageName: String): Boolean suspend fun checkAllForUpdates() suspend fun updateAppVersion( packageName: String, newTag: String, newAssetName: String, newAssetUrl: String, newVersionName: String, newVersionCode: Long, signingFingerprint: String?, ) suspend fun updateApp(app: InstalledApp) suspend fun updatePendingStatus( packageName: String, isPending: Boolean, ) suspend fun executeInTransaction(block: suspend () -> R): R } ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/ProxyRepository.kt ================================================ package zed.rainxch.core.domain.repository import kotlinx.coroutines.flow.Flow import zed.rainxch.core.domain.model.ProxyConfig interface ProxyRepository { fun getProxyConfig(): Flow suspend fun setProxyConfig(config: ProxyConfig) } ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/RateLimitRepository.kt ================================================ package zed.rainxch.core.domain.repository import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import zed.rainxch.core.domain.model.RateLimitInfo interface RateLimitRepository { val rateLimitState: StateFlow val rateLimitExhaustedEvent: SharedFlow fun updateRateLimit(rateLimitInfo: RateLimitInfo?) fun getCurrentRateLimit(): RateLimitInfo? fun isCurrentlyLimited(): Boolean fun clear() } ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/SeenReposRepository.kt ================================================ package zed.rainxch.core.domain.repository import kotlinx.coroutines.flow.Flow interface SeenReposRepository { fun getAllSeenRepoIds(): Flow> suspend fun markAsSeen(repoId: Long) suspend fun clearAll() } ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/StarredRepository.kt ================================================ package zed.rainxch.core.domain.repository import kotlinx.coroutines.flow.Flow import zed.rainxch.core.domain.model.StarredRepository interface StarredRepository { fun getAllStarred(): Flow> suspend fun isStarred(repoId: Long): Boolean suspend fun syncStarredRepos(forceRefresh: Boolean = false): Result suspend fun getLastSyncTime(): Long? suspend fun needsSync(): Boolean } ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TweaksRepository.kt ================================================ package zed.rainxch.core.domain.repository import kotlinx.coroutines.flow.Flow import zed.rainxch.core.domain.model.AppTheme import zed.rainxch.core.domain.model.FontTheme import zed.rainxch.core.domain.model.InstallerType interface TweaksRepository { fun getThemeColor(): Flow suspend fun setThemeColor(theme: AppTheme) fun getIsDarkTheme(): Flow suspend fun setDarkTheme(isDarkTheme: Boolean?) fun getAmoledTheme(): Flow suspend fun setAmoledTheme(enabled: Boolean) fun getFontTheme(): Flow suspend fun setFontTheme(fontTheme: FontTheme) fun getAutoDetectClipboardLinks(): Flow suspend fun setAutoDetectClipboardLinks(enabled: Boolean) fun getInstallerType(): Flow suspend fun setInstallerType(type: InstallerType) fun getAutoUpdateEnabled(): Flow suspend fun setAutoUpdateEnabled(enabled: Boolean) fun getUpdateCheckInterval(): Flow suspend fun setUpdateCheckInterval(hours: Long) fun getIncludePreReleases(): Flow suspend fun setIncludePreReleases(enabled: Boolean) fun getLiquidGlassEnabled(): Flow suspend fun setLiquidGlassEnabled(enabled: Boolean) fun getHideSeenEnabled(): Flow suspend fun setHideSeenEnabled(enabled: Boolean) } ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/Installer.kt ================================================ package zed.rainxch.core.domain.system import zed.rainxch.core.domain.model.GithubAsset import zed.rainxch.core.domain.model.SystemArchitecture interface Installer { suspend fun isSupported(extOrMime: String): Boolean suspend fun ensurePermissionsOrThrow(extOrMime: String) suspend fun install( filePath: String, extOrMime: String, ) fun uninstall(packageName: String) fun isAssetInstallable(assetName: String): Boolean fun choosePrimaryAsset(assets: List): GithubAsset? fun detectSystemArchitecture(): SystemArchitecture fun isObtainiumInstalled(): Boolean fun openInObtainium( repoOwner: String, repoName: String, onOpenInstaller: () -> Unit, ) fun isAppManagerInstalled(): Boolean fun openInAppManager( filePath: String, onOpenInstaller: () -> Unit, ) fun getApkInfoExtractor(): InstallerInfoExtractor fun openApp(packageName: String): Boolean fun openWithExternalInstaller(filePath: String) } ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/InstallerInfoExtractor.kt ================================================ package zed.rainxch.core.domain.system import zed.rainxch.core.domain.model.ApkPackageInfo interface InstallerInfoExtractor { suspend fun extractPackageInfo(filePath: String): ApkPackageInfo? } ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/InstallerStatusProvider.kt ================================================ package zed.rainxch.core.domain.system import kotlinx.coroutines.flow.StateFlow import zed.rainxch.core.domain.model.ShizukuAvailability interface InstallerStatusProvider { val shizukuAvailability: StateFlow fun requestShizukuPermission() } ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/PackageMonitor.kt ================================================ package zed.rainxch.core.domain.system import zed.rainxch.core.domain.model.DeviceApp import zed.rainxch.core.domain.model.SystemPackageInfo interface PackageMonitor { suspend fun isPackageInstalled(packageName: String): Boolean suspend fun getInstalledPackageInfo(packageName: String): SystemPackageInfo? suspend fun getAllInstalledPackageNames(): Set suspend fun getAllInstalledApps(): List } ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/UpdateScheduleManager.kt ================================================ package zed.rainxch.core.domain.system /** * Abstraction for rescheduling background update checks. * Android implementation delegates to WorkManager; Desktop is a no-op. */ interface UpdateScheduleManager { /** * Reschedules the periodic update check with a new interval. * Takes effect immediately (replaces existing schedule). */ fun reschedule(intervalHours: Long) } ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/use_cases/SyncInstalledAppsUseCase.kt ================================================ package zed.rainxch.core.domain.use_cases import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext import zed.rainxch.core.domain.logging.GitHubStoreLogger import zed.rainxch.core.domain.model.InstalledApp import zed.rainxch.core.domain.model.Platform import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.system.PackageMonitor /** * Use case for synchronizing installed apps state with the system package manager. * * Responsibilities: * 1. Remove apps from DB that are no longer installed on the system * 2. Migrate legacy apps missing versionName/versionCode fields * 3. Resolve pending installs once they appear in the system package manager * 4. Clean up stale pending installs (older than 24 hours) * 5. Detect external version changes (downgrades on rooted devices, sideloads, etc.) * * This should be called before loading or refreshing app data to ensure consistency. */ class SyncInstalledAppsUseCase( private val packageMonitor: PackageMonitor, private val installedAppsRepository: InstalledAppsRepository, private val platform: Platform, private val logger: GitHubStoreLogger, ) { companion object { private const val PENDING_TIMEOUT_MS = 24 * 60 * 60 * 1000L // 24 hours } suspend operator fun invoke(): Result = withContext(Dispatchers.IO) { try { val installedPackageNames = packageMonitor.getAllInstalledPackageNames() val appsInDb = installedAppsRepository.getAllInstalledApps().first() val now = System.currentTimeMillis() val toDelete = mutableListOf() val toMigrate = mutableListOf>() val toResolvePending = mutableListOf() val toDeleteStalePending = mutableListOf() val toSyncVersions = mutableListOf() appsInDb.forEach { app -> val isOnSystem = installedPackageNames.contains(app.packageName) when { app.isPendingInstall -> { if (isOnSystem) { toResolvePending.add(app) } else if (now - app.installedAt > PENDING_TIMEOUT_MS) { toDeleteStalePending.add(app.packageName) } } !isOnSystem -> { toDelete.add(app.packageName) } app.installedVersionName == null -> { val migrationResult = determineMigrationData(app) toMigrate.add(app.packageName to migrationResult) } // Detect external version changes (downgrades on rooted devices, sideloads, etc.) isOnSystem && platform == Platform.ANDROID -> { toSyncVersions.add(app) } } } executeInTransaction { toDelete.forEach { packageName -> try { installedAppsRepository.deleteInstalledApp(packageName) logger.info("Removed uninstalled app: $packageName") } catch (e: Exception) { logger.error("Failed to delete $packageName: ${e.message}") } } toDeleteStalePending.forEach { packageName -> try { installedAppsRepository.deleteInstalledApp(packageName) logger.info("Removed stale pending install (>24h): $packageName") } catch (e: Exception) { logger.error("Failed to delete stale pending $packageName: ${e.message}") } } toResolvePending.forEach { app -> try { val systemInfo = packageMonitor.getInstalledPackageInfo(app.packageName) if (systemInfo != null) { val latestVersionCode = app.latestVersionCode ?: 0L installedAppsRepository.updateApp( app.copy( isPendingInstall = false, installedVersionName = systemInfo.versionName, installedVersionCode = systemInfo.versionCode, isUpdateAvailable = latestVersionCode > systemInfo.versionCode, ), ) logger.info( "Resolved pending install: ${app.packageName} (v${systemInfo.versionName}, code=${systemInfo.versionCode})", ) } else { installedAppsRepository.updatePendingStatus(app.packageName, false) logger.info("Resolved pending install (no system info): ${app.packageName}") } } catch (e: Exception) { logger.error("Failed to resolve pending ${app.packageName}: ${e.message}") } } toMigrate.forEach { (packageName, migrationResult) -> try { val app = appsInDb.find { it.packageName == packageName } ?: return@forEach installedAppsRepository.updateApp( app.copy( installedVersionName = migrationResult.versionName, installedVersionCode = migrationResult.versionCode, latestVersionName = migrationResult.versionName, latestVersionCode = migrationResult.versionCode, ), ) logger.info( "Migrated $packageName: ${migrationResult.source} " + "(versionName=${migrationResult.versionName}, code=${migrationResult.versionCode})", ) } catch (e: Exception) { logger.error("Failed to migrate $packageName: ${e.message}") } } toSyncVersions.forEach { app -> try { val systemInfo = packageMonitor.getInstalledPackageInfo(app.packageName) if (systemInfo != null && systemInfo.versionCode != app.installedVersionCode) { val wasDowngrade = systemInfo.versionCode < app.installedVersionCode val latestVersionCode = app.latestVersionCode ?: 0L val isUpdateAvailable = latestVersionCode > systemInfo.versionCode installedAppsRepository.updateApp( app.copy( installedVersionName = systemInfo.versionName, installedVersionCode = systemInfo.versionCode, installedVersion = systemInfo.versionName, isUpdateAvailable = isUpdateAvailable, ), ) val action = if (wasDowngrade) "downgrade" else "external update" logger.info( "Detected $action for ${app.packageName}: " + "DB v${app.installedVersionName}(${app.installedVersionCode}) → " + "System v${systemInfo.versionName}(${systemInfo.versionCode}), " + "updateAvailable=$isUpdateAvailable", ) } } catch (e: Exception) { logger.error("Failed to sync version for ${app.packageName}: ${e.message}") } } } logger.info( "Sync completed: ${toDelete.size} deleted, ${toDeleteStalePending.size} stale pending removed, " + "${toResolvePending.size} pending resolved, ${toMigrate.size} migrated, " + "${toSyncVersions.size} version-checked", ) Result.success(Unit) } catch (e: Exception) { logger.error("Sync failed: ${e.message}") Result.failure(e) } } private suspend fun determineMigrationData(app: InstalledApp): MigrationResult = if (platform == Platform.ANDROID) { val systemInfo = packageMonitor.getInstalledPackageInfo(app.packageName) if (systemInfo != null) { MigrationResult( versionName = systemInfo.versionName, versionCode = systemInfo.versionCode, source = "system package manager", ) } else { MigrationResult( versionName = app.installedVersion, versionCode = 0L, source = "fallback to release tag", ) } } else { MigrationResult( versionName = app.installedVersion, versionCode = 0L, source = "desktop fallback to release tag", ) } private data class MigrationResult( val versionName: String, val versionCode: Long, val source: String, ) } suspend fun executeInTransaction(block: suspend () -> Unit) { try { block() } catch (e: Exception) { throw e } } ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/utils/AppLauncher.kt ================================================ package zed.rainxch.core.domain.utils import zed.rainxch.core.domain.model.InstalledApp interface AppLauncher { suspend fun launchApp(installedApp: InstalledApp): Result suspend fun canLaunchApp(installedApp: InstalledApp): Boolean } ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/utils/BrowserHelper.kt ================================================ package zed.rainxch.core.domain.utils interface BrowserHelper { fun openUrl( url: String, onFailure: (error: String) -> Unit = { }, ) } ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/utils/ClipboardHelper.kt ================================================ package zed.rainxch.core.domain.utils interface ClipboardHelper { fun copy( label: String, text: String, ) fun getText(): String? } ================================================ FILE: core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/utils/ShareManager.kt ================================================ package zed.rainxch.core.domain.utils interface ShareManager { fun shareText(text: String) fun shareFile(fileName: String, content: String, mimeType: String = "application/json") fun pickFile(mimeType: String = "application/json", onResult: (String?) -> Unit) } ================================================ FILE: core/domain/src/jvmMain/kotlin/zed/rainxch/core/domain/Platform.jvm.kt ================================================ package zed.rainxch.core.domain import zed.rainxch.core.domain.model.Platform actual fun getPlatform(): Platform = when { System.getProperty("os.name").lowercase().contains("win") -> Platform.WINDOWS System.getProperty("os.name").lowercase().contains("mac") -> Platform.MACOS else -> Platform.LINUX } ================================================ FILE: core/presentation/.gitignore ================================================ /build ================================================ FILE: core/presentation/build.gradle.kts ================================================ plugins { alias(libs.plugins.convention.cmp.library) } kotlin { sourceSets { commonMain { dependencies { implementation(libs.kotlin.stdlib) implementation(projects.core.domain) implementation(libs.bundles.landscapist) implementation(libs.liquid) implementation(libs.jetbrains.lifecycle.compose) implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.collections.immutable) implementation(compose.components.resources) implementation(libs.androidx.compose.ui.tooling.preview) } } } } compose.resources { publicResClass = true packageOfResClass = "zed.rainxch.githubstore.core.presentation.res" generateResClass = auto } ================================================ FILE: core/presentation/src/androidMain/AndroidManifest.xml ================================================ ================================================ FILE: core/presentation/src/androidMain/kotlin/zed/rainxch/core/presentation/theme/Theme.android.kt ================================================ package zed.rainxch.core.presentation.theme import android.os.Build import androidx.annotation.ChecksSdkIntAtLeast import androidx.compose.material3.ColorScheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S) actual fun isDynamicColorAvailable(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S @Composable actual fun getDynamicColorScheme(darkTheme: Boolean): ColorScheme? { if (!isDynamicColorAvailable()) return null val context = LocalContext.current return if (darkTheme) { dynamicDarkColorScheme(context) } else { dynamicLightColorScheme(context) } } ================================================ FILE: core/presentation/src/androidMain/kotlin/zed/rainxch/core/presentation/utils/ApplyAndroidSystemBars.android.kt ================================================ package zed.rainxch.core.presentation.utils import android.app.Activity import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.platform.LocalContext import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsControllerCompat @Composable actual fun ApplyAndroidSystemBars(isDarkTheme: Boolean?) { val context = LocalContext.current val activity = context as? Activity ?: return val isDark = isDarkTheme ?: isSystemInDarkTheme() DisposableEffect(isDark) { activity.updateSystemBars(isDark) onDispose { } } } fun Activity.updateSystemBars(isDarkTheme: Boolean) { WindowCompat.setDecorFitsSystemWindows(window, false) val controller = WindowInsetsControllerCompat(window, window.decorView) controller.isAppearanceLightStatusBars = !isDarkTheme controller.isAppearanceLightNavigationBars = !isDarkTheme } ================================================ FILE: core/presentation/src/androidMain/kotlin/zed/rainxch/core/presentation/utils/isLiquidFrostAvailable.android.kt ================================================ package zed.rainxch.core.presentation.utils import android.os.Build import androidx.annotation.ChecksSdkIntAtLeast @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.TIRAMISU) actual fun isLiquidFrostAvailable(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU ================================================ FILE: core/presentation/src/commonMain/composeResources/drawable/ic_github.xml ================================================ ================================================ FILE: core/presentation/src/commonMain/composeResources/drawable/ic_platform_android.xml ================================================ ================================================ FILE: core/presentation/src/commonMain/composeResources/drawable/ic_platform_linux.xml ================================================ ================================================ FILE: core/presentation/src/commonMain/composeResources/drawable/ic_platform_macos.xml ================================================ ================================================ FILE: core/presentation/src/commonMain/composeResources/drawable/ic_platform_windows.xml ================================================ ================================================ FILE: core/presentation/src/commonMain/composeResources/values/strings.xml ================================================ GitHub Store Installed Apps Navigate back Check for updates Cannot launch %1$s Failed to open %1$s Failed to update %1$s: %2$s Update failed Update all failed: %1$s All apps updated successfully No updates available Search your apps No apps found Update All Update Open Cancel Checking… Updated successfully Error: %1$s Updating %1$d of %2$d Currently: %1$s Waiting for authorization… Signed in! You can now use the app. Redirecting… Try again Error: %1$s Enter this code on GitHub: Copy the code Open GitHub Unlock the Full\nExperience More Requests Sign in to get higher API rate limits and avoid interruptions. Sign in with GitHub Cancelled Unknown error Language: Discover Repositories Search repo, description… Filter by Language %1$d results were found Sort by Close Most Stars Most Forks Best Match Descending Ascending Sort All Languages Kotlin Java JavaScript TypeScript Python Swift Rust Go C# C++ C Dart Ruby PHP Search failed No repositories found Profile APPEARANCE NETWORK ABOUT Theme Color AMOLED Black Theme Pure black background for dark mode Selected color: %1$s Version Help & Support Logout Proxy Type None System HTTP SOCKS Host Port Username (optional) Password (optional) Save Proxy Proxy settings saved Uses your device's proxy settings Port must be 1–65535 Direct connection, no proxy Failed to save proxy settings Proxy host is required Invalid proxy port Show password Hide password Logged out successfully, redirecting... Warning! Are you sure you want to logout? Dynamic Ocean Purple Forest Slate Amber Open repository Open in browser Cancel download Show install options Error loading details Retry No description provided. No release notes. About this app Install logs Author What’s New Installed Update available Not available Install latest Reinstall Update app Report issue Downloading Updating Verifying Installing Pending install Uninstall Open Downgrade requires uninstall Installing version %1$s requires uninstalling the current version (%2$s) first. Your app data will be lost. Uninstall first Signing key changed The signing certificate for this app has changed since it was first installed.\n\nThis could mean the developer rotated their signing key, or the binary may have been tampered with.\n\nExpected: %1$s\nReceived: %2$s Install anyway Verified build Checking\u2026 Install %1$s Failed to open %1$s Failed to uninstall %1$s Open in Obtainium Manage updates automatically Inspect with AppManager Check permissions, trackers & security Profile Forks Stars Issues by %1$s • Installed: %1$s Architecture compatible Update to %1$s Failed to load details Installer was saved into the Downloads folder Download started Downloaded Update started Installed Updated Cancelled Installation started Error Error: %1$s Preparing for AppManager Opened in AppManager Install permission blocked by device policy Opened in external installer Install permission unavailable The APK was downloaded successfully but this device doesn\'t allow direct installation. Would you like to open it with an external installer? Open with external installer Use a third-party app to install the APK Error: %1$s Asset type .%1$s not supported Downloaded file not found Trending Hot release Most popular Finding repositories... Loading more... No more repositories Retry Failed to load repositories View Details updated just now updated %1$d hour(s) ago updated yesterday updated %1$d day(s) ago updated on %1$s Rate Limit Exceeded You've used all %1$d API requests. You've used all %1$d free API requests. Resets in %1$d minutes 💡 Sign in to get 5,000 requests per hour instead of 60! Sign In OK Close System font Match your device's font for better readability Light Dark System Repository added to favourites Repository removed from favourites Add to favourites Remove from favourites Favourites added just now added %1$d hour(s) ago added yesterday added %1$d day(s) ago added on %1$s Starred Repositories Repository is starred Repository is not starred You can star a repository from GitHub You can unstar a repository from GitHub Sign in required Sign in with GitHub to see your starred repositories No starred repositories Star repositories on GitHub with installable releases to see them here Last synced Just now %d min ago %d h ago %d d ago Dismiss Failed to sync starred repos Developer Profile Open developer profile Failed to load repositories Failed to load profile Repositories Followers Following Search repositories… Clear search All With Releases Installed Favorites Sort Recently Updated Name repository repositories Showing %1$d of %2$d repositories No repositories with installable releases No installed repositories No favorite repositories Updated %1$s Has Release %1$d y ago %1$d mo ago %1$d d ago %1$d h ago %1$d m ago %1$dM %1$dk Released just now Released %1$d hour(s) ago Released yesterday Released %1$d day(s) ago Released on %1$s Home Search Apps Profile Fork Stable Pre-release All Select version Pre-release Latest No version Versions Assets No Assets No assets associated with this release Select Asset Option Multiple Assets Available There are multiple installable files and application downloaders available for this release. Please review the list and select the one that matches your device requirements. Info Last checked: %1$s Never checked just now %1$d min ago %1$d h ago Checking for updates… Track this app App added to tracking list Failed to track app: %1$s App is already being tracked Sign in to GitHub Unlock the full experience. Manage your apps, sync your preferences, and browse faster. Repos Login Your Starred Repositories from GitHub Your Favourite Repositories saved locally Session Expired Your GitHub session has expired or the token was revoked. Please sign in again to continue using authenticated features. You can still browse as a guest with limited API requests. Sign In Again Continue as Guest This will clear your local session and cached data. To fully revoke access, visit GitHub Settings > Applications. Code expires in %1$s The device code has expired. Please try signing in again to get a new code. Please check your internet connection and try again. You denied the authorization request. Try again if this was unintentional. Read More Show Less Share repository Failed to share link Link copied to clipboard Translate Translating… Show original Translated to %1$s Translate to… Search language Change language Translation failed. Please try again. Retry Auto-detected: %1$s Select language Open GitHub Link GitHub link detected in clipboard Auto-detect clipboard links Automatically detect GitHub links from clipboard when opening search Detected Links Open in app No GitHub link found in clipboard Storage Clear cache Current size: Clear The cache is gradually cleared. Support GitHub Store Support the project Built with love,\nmaintained with coffee GitHub Store has reached over 130,000 downloads and 7,700 GitHub stars — 100% free, with no ads and no tracking. I developed and maintain this project completely on my own while finishing school. Your support — even a small one — helps pay for infrastructure and continue developing the app. Vote for GitHub Store! GitHub Store has been nominated for the Golden Kodee Awards at KotlinConf 2026. 1. Register 2. Vote Voting until March 22 1. Register on the platform (Sign in with Google) 2. Click “Vote” below 3. Find Usmon Narzullayev and click “Vote” GitHub Sponsors Recurring or one-time support via GitHub Buy Me a Coffee Quick one-time support OTHER WAYS TO HELP Star the repository Helps others discover GitHub Store Report a bug Helps make the app better Share with friends Tell other developers about it Any support — financial or not — helps keep the project alive. Thank you! Installation Default Standard system install dialog Shizuku Silent install without prompts Shizuku is not installed Shizuku is not running Permission required Ready Grant Permission Install Shizuku to enable silent installs Start Shizuku to enable silent installs Shizuku install failed, using standard installer Auto-update apps Automatically download and install updates in background via Shizuku Updates Update check interval How often to check for app updates in background 3h 6h 12h 24h Add by link Link app to repository Pick an installed app to link with a GitHub repository Search apps… GitHub repository URL github.com/owner/repo Validating… Link & Track Checking latest release… Downloading APK for verification… Verifying signing key… Package name mismatch: the APK is %1$s, but the selected app is %2$s Signing key mismatch: the APK in this repository was signed by a different developer than the installed app Select installer Pick the APK to verify against your installed app Download failed Export Import Import apps Paste the exported JSON to restore your tracked apps Paste exported JSON here… Include pre-releases Track pre-release versions when checking for updates. When disabled, only stable releases are considered. Uninstall app? Are you sure you want to uninstall %1$s? This action cannot be undone and app data may be lost. Invalid GitHub URL. Use format: github.com/owner/repo Repository not found: %1$s/%2$s GitHub API rate limit exceeded. Try again later. Failed to link: %1$s Failed to load installed apps %1$s linked to %2$s/%3$s Export failed: %1$s Import failed: %1$s Imported %1$d apps , %1$d skipped , %1$d failed Package mismatch: the APK is %1$s, but the installed app is %2$s. Update blocked. Signing key mismatch: the update was signed by a different developer. Update blocked. Liquid Glass Effect Enhance the interface with a smooth glass-like appearance Hide Seen Repositories Hide repositories you have already viewed from discovery feeds Clear Seen History Reset all seen repositories so they appear again in feeds Seen history cleared Viewed ================================================ FILE: core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml ================================================ GitHub Store التطبيقات المثبتة العودة التحقق من التحديثات تعذر تشغيل %1$s فشل فتح %1$s فشل تحديث %1$s: %2$s فشل التحديث فشل تحديث الكل: %1$s تم تحديث جميع التطبيقات بنجاح لا توجد تحديثات متاحة ابحث في تطبيقاتك لم يتم العثور على تطبيقات تحديث الكل تحديث فتح إلغاء جارٍ التحقق… تم التحديث بنجاح خطأ: %1$s جارٍ تحديث %1$d من %2$d الحالي: %1$s في انتظار التفويض… تم تسجيل الدخول! يمكنك الآن استخدام التطبيق. جارٍ إعادة التوجيه… حاول مرة أخرى خطأ: %1$s أدخل هذا الرمز على GitHub: نسخ الرمز فتح GitHub افتح التجربة\nالكاملة المزيد من الطلبات سجّل الدخول للحصول على حدود أعلى لطلبات API وتجنب الانقطاعات. تسجيل الدخول بـ GitHub تم الإلغاء خطأ غير معروف اللغة: اكتشف المستودعات ابحث عن مستودع، وصف… تصفية حسب اللغة تم العثور على %1$d نتيجة ترتيب حسب إغلاق الأكثر نجوماً الأكثر تفرعاً الأفضل تطابقاً تنازلي تصاعدي ترتيب جميع اللغات Kotlin Java JavaScript TypeScript Python Swift Rust Go C# C++ C Dart Ruby PHP فشل البحث لم يتم العثور على مستودعات الملف الشخصي المظهر الشبكة حول لون السمة سمة AMOLED السوداء خلفية سوداء نقية للوضع الداكن اللون المحدد: %1$s الإصدار المساعدة والدعم تسجيل الخروج نوع الوكيل بدون النظام HTTP SOCKS المضيف المنفذ اسم المستخدم (اختياري) كلمة المرور (اختياري) حفظ الوكيل تم حفظ إعدادات الوكيل يستخدم إعدادات الوكيل الخاصة بجهازك يجب أن يكون المنفذ بين 1–65535 اتصال مباشر، بدون وكيل فشل حفظ إعدادات الوكيل المضيف مطلوب منفذ الوكيل غير صالح إظهار كلمة المرور إخفاء كلمة المرور تم تسجيل الخروج بنجاح، جارٍ إعادة التوجيه... تم مسح ذاكرة التخزين المؤقت بنجاح تحذير! هل أنت متأكد أنك تريد تسجيل الخروج؟ ديناميكي محيط بنفسجي غابة رمادي كهرماني فتح المستودع فتح في المتصفح إلغاء التنزيل عرض خيارات التثبيت خطأ في تحميل التفاصيل إعادة المحاولة لا يوجد وصف. لا توجد ملاحظات إصدار. الإبلاغ عن مشكلة حول هذا التطبيق سجلات التثبيت المؤلف ما الجديد مثبت تحديث متاح غير متاح تثبيت الأحدث إعادة التثبيت تحديث التطبيق جارٍ التنزيل جارٍ التحديث جارٍ التحقق جارٍ التثبيت في انتظار التثبيت إزالة التثبيت فتح الرجوع لإصدار أقدم يتطلب إزالة التثبيت تثبيت الإصدار %1$s يتطلب إزالة الإصدار الحالي (%2$s) أولاً. سيتم فقدان بيانات التطبيق. إزالة التثبيت أولاً تثبيت %1$s فشل فتح %1$s فشل إزالة تثبيت %1$s فتح في Obtainium إدارة التحديثات تلقائياً فحص بـ AppManager فحص الأذونات والمتتبعات والأمان الملف الشخصي التفرعات النجوم المشكلات بواسطة %1$s • المثبت: %1$s متوافق مع المعمارية التحديث إلى %1$s فشل تحميل التفاصيل تم حفظ المثبت في مجلد التنزيلات بدأ التنزيل تم التنزيل بدأ التحديث تم التثبيت تم التحديث تم الإلغاء بدأ التثبيت خطأ خطأ: %1$s التحضير لـ AppManager تم الفتح في AppManager تم حظر إذن التثبيت بواسطة سياسة الجهاز تم الفتح في المثبت الخارجي إذن التثبيت غير متاح تم تنزيل ملف APK بنجاح لكن هذا الجهاز لا يسمح بالتثبيت المباشر. هل تريد فتحه بمثبت خارجي؟ فتح بمثبت خارجي استخدم تطبيقاً خارجياً لتثبيت ملف APK خطأ: %1$s نوع الملف .%1$s غير مدعوم لم يتم العثور على الملف المنزّل الرائج إصدار ساخن الأكثر شعبية جارٍ البحث عن مستودعات... جارٍ تحميل المزيد... لا مزيد من المستودعات إعادة المحاولة فشل تحميل المستودعات عرض التفاصيل تم التحديث للتو تم التحديث منذ %1$d ساعة تم التحديث أمس تم التحديث منذ %1$d يوم تم التحديث في %1$s تم تجاوز حد الطلبات لقد استخدمت جميع طلبات API البالغة %1$d. لقد استخدمت جميع الطلبات المجانية البالغة %1$d. يتم إعادة التعيين خلال %1$d دقيقة 💡 سجّل الدخول للحصول على 5,000 طلب في الساعة بدلاً من 60! تسجيل الدخول حسناً إغلاق خط النظام مطابقة خط جهازك لقراءة أفضل فاتح داكن النظام تمت إضافة المستودع إلى المفضلة تمت إزالة المستودع من المفضلة إضافة إلى المفضلة إزالة من المفضلة المفضلة أُضيف للتو أُضيف منذ %1$d ساعة أُضيف أمس أُضيف منذ %1$d يوم أُضيف في %1$s المستودعات المميزة بنجمة المستودع مميز بنجمة المستودع غير مميز بنجمة يمكنك تمييز مستودع بنجمة من GitHub يمكنك إزالة النجمة عن مستودع من GitHub تسجيل الدخول مطلوب سجّل الدخول بـ GitHub لرؤية مستودعاتك المميزة بنجمة لا توجد مستودعات مميزة بنجمة ميّز المستودعات بنجمة على GitHub التي تحتوي على إصدارات قابلة للتثبيت لتظهر هنا آخر مزامنة الآن منذ %1$d دقيقة منذ %1$d ساعة منذ %1$d يوم تجاهل فشلت مزامنة المستودعات المميزة بنجمة الملف الشخصي للمطور فتح الملف الشخصي للمطور فشل تحميل المستودعات فشل تحميل الملف الشخصي المستودعات المتابعون يتابع البحث في المستودعات… مسح البحث الكل مع إصدارات المثبتة المفضلة ترتيب المحدّث مؤخراً الاسم مستودع مستودعات عرض %1$d من %2$d مستودع لا توجد مستودعات بإصدارات قابلة للتثبيت لا توجد مستودعات مثبتة لا توجد مستودعات مفضلة تم التحديث %1$s يحتوي على إصدار منذ %1$d سنة منذ %1$d شهر منذ %1$d يوم منذ %1$d ساعة منذ %1$d دقيقة %1$d مليون %1$d ألف صدر للتو صدر منذ %1$d ساعة صدر أمس صدر منذ %1$d يوم صدر في %1$s الرئيسية البحث التطبيقات الملف الشخصي نسخة متفرعة مستقر إصدار تجريبي الكل اختر الإصدار تجريبي الأحدث لا يوجد إصدار الإصدارات آخر فحص: %1$s لم يتم الفحص مطلقاً الآن منذ %1$d دقيقة منذ %1$d ساعة جارٍ التحقق من التحديثات… تتبع هذا التطبيق تمت إضافة التطبيق إلى قائمة التتبع فشل تتبع التطبيق: %1$s التطبيق قيد التتبع بالفعل تسجيل الدخول إلى GitHub افتح التجربة الكاملة. أدر تطبيقاتك، زامن تفضيلاتك، وتصفح بشكل أسرع. المستودعات تسجيل الدخول مستودعاتك المميزة بنجمة من GitHub مستودعاتك المفضلة المحفوظة محلياً انتهت الجلسة انتهت جلسة GitHub الخاصة بك أو تم إلغاء الرمز المميز. يرجى تسجيل الدخول مرة أخرى لمتابعة استخدام الميزات المصادق عليها. لا يزال بإمكانك التصفح كضيف مع طلبات API محدودة. تسجيل الدخول مرة أخرى المتابعة كضيف سيؤدي هذا إلى مسح جلستك المحلية والبيانات المخزنة مؤقتاً. لإلغاء الوصول بالكامل، قم بزيارة إعدادات GitHub > التطبيقات. ينتهي الرمز خلال %1$s انتهت صلاحية رمز الجهاز. يرجى محاولة تسجيل الدخول مرة أخرى للحصول على رمز جديد. يرجى التحقق من اتصالك بالإنترنت والمحاولة مرة أخرى. لقد رفضت طلب التفويض. حاول مرة أخرى إذا كان ذلك غير مقصود. اقرأ المزيد عرض أقل مشاركة المستودع فشل مشاركة الرابط تم نسخ الرابط إلى الحافظة ترجمة جارٍ الترجمة… عرض الأصلي تُرجم إلى %1$s ترجمة إلى… البحث عن لغة تغيير اللغة فشلت الترجمة. يرجى المحاولة مرة أخرى. فتح رابط GitHub تم اكتشاف رابط GitHub في الحافظة الكشف التلقائي عن روابط الحافظة الكشف التلقائي عن روابط GitHub من الحافظة عند فتح البحث الروابط المكتشفة فتح في التطبيق لم يتم العثور على رابط GitHub في الحافظة التخزين مسح ذاكرة التخزين المؤقت الحجم الحالي: مسح ادعم GitHub Store ادعم المشروع صُنع بحب،\nويستمر بالقهوة وصل GitHub Store إلى أكثر من 130,000 تنزيل و 7,700 نجمة على GitHub — مجاني 100٪، بدون إعلانات أو تتبع. أقوم ببناء وصيانة هذا المشروع بالكامل بمفردي أثناء إنهاء دراستي الثانوية. صوّت لـ GitHub Store! تم ترشيح GitHub Store لجوائز Golden Kodee في KotlinConf 2026. 1. التسجيل 2. التصويت ينتهي التصويت في 22 مارس 1. سجل في منصة الجوائز (المتابعة عبر Google) 2. اضغط على زر التصويت أدناه 3. ابحث عن Usmon Narzullayev واضغط تصويت GitHub Sponsors دعم متكرر أو لمرة واحدة عبر GitHub Buy Me a Coffee دعم سريع لمرة واحدة طرق أخرى للمساعدة ضع نجمة للمستودع يساعد الآخرين على اكتشاف GitHub Store الإبلاغ عن الأخطاء يجعل التطبيق أفضل للجميع شارك مع الأصدقاء انشر الخبر بين المطورين كل دعم — مالي أو غير ذلك — يساعد في إبقاء هذا المشروع حيًا. شكرًا لك! التثبيت الافتراضي نافذة التثبيت القياسية للنظام Shizuku تثبيت صامت بدون مطالبات Shizuku غير مثبت Shizuku لا يعمل يتطلب إذنًا جاهز منح الإذن ثبّت Shizuku لتفعيل التثبيت الصامت شغّل Shizuku لتفعيل التثبيت الصامت فشل تثبيت Shizuku، يتم استخدام المثبت القياسي تحديث التطبيقات تلقائيًا تنزيل التحديثات وتثبيتها تلقائيًا في الخلفية عبر Shizuku التحديثات فترة التحقق من التحديثات عدد مرات التحقق من تحديثات التطبيق في الخلفية ٣ ساعات ٦ ساعات ١٢ ساعة ٢٤ ساعة إضافة عبر رابط ربط التطبيق بالمستودع اختر تطبيقاً مثبتاً لربطه بمستودع GitHub البحث عن التطبيقات… رابط مستودع GitHub github.com/owner/repo جارٍ التحقق… ربط وتتبع التحقق من آخر إصدار… تنزيل APK للتحقق… التحقق من مفتاح التوقيع… عدم تطابق اسم الحزمة: ملف APK هو %1$s، لكن التطبيق المحدد هو %2$s عدم تطابق مفتاح التوقيع: ملف APK في هذا المستودع موقّع من مطور مختلف اختر المثبّت اختر ملف APK للتحقق من مطابقته للتطبيق المثبت فشل التنزيل تصدير استيراد استيراد التطبيقات الصق ملف JSON المُصدَّر لاستعادة التطبيقات المتتبعة الصق JSON المُصدَّر هنا… تضمين الإصدارات التجريبية تتبع الإصدارات التجريبية عند التحقق من التحديثات. عند التعطيل، يتم اعتبار الإصدارات المستقرة فقط. إلغاء تثبيت التطبيق؟ هل أنت متأكد من إلغاء تثبيت %1$s؟ لا يمكن التراجع عن هذا الإجراء وقد تُفقد بيانات التطبيق. رابط GitHub غير صالح. استخدم التنسيق: github.com/owner/repo المستودع غير موجود: %1$s/%2$s تم تجاوز حد طلبات GitHub API. حاول لاحقاً. فشل الربط: %1$s فشل تحميل التطبيقات المثبتة تم ربط %1$s بـ %2$s/%3$s فشل التصدير: %1$s فشل الاستيراد: %1$s تم استيراد %1$d تطبيقات ، %1$d تم تخطيها ، %1$d فشلت تغيّر مفتاح التوقيع تغيّرت شهادة توقيع هذا التطبيق منذ تثبيته لأول مرة.\n\nقد يعني هذا أن المطور غيّر مفتاح التوقيع، أو أن الملف قد تم التلاعب به.\n\nالمتوقع: %1$s\nالمستلم: %2$s التثبيت على أي حال بناء موثق جارٍ التحقق\u2026 الملفات لا توجد ملفات لا توجد ملفات مرتبطة بهذا الإصدار اختر خيار الملف ملفات متعددة متاحة تتوفر عدة ملفات قابلة للتثبيت لهذا الإصدار. يرجى مراجعة القائمة واختيار الملف المناسب لجهازك. معلومات إعادة المحاولة اكتشاف تلقائي: %1$s اختر اللغة عدم تطابق الحزمة: ملف APK هو %1$s، لكن التطبيق المثبت هو %2$s. تم حظر التحديث. عدم تطابق مفتاح التوقيع: تم توقيع التحديث بواسطة مطور مختلف. تم حظر التحديث. تأثير الزجاج السائل تحسين الواجهة بمظهر زجاجي ناعم إخفاء المستودعات المشاهَدة إخفاء المستودعات التي شاهدتها بالفعل من خلاصات الاكتشاف مسح سجل المشاهدة إعادة تعيين جميع المستودعات المشاهَدة لتظهر مجدداً في الخلاصات تم مسح سجل المشاهدة تمت المشاهدة ================================================ FILE: core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml ================================================ GitHub Store ইনস্টল করা অ্যাপসমূহ পেছনে যান আপডেট পরীক্ষা করুন %1$s চালু করা যায়নি %1$s খুলতে ব্যর্থ %1$s আপডেট করতে ব্যর্থ: %2$s আপডেট ব্যর্থ সব আপডেট ব্যর্থ হয়েছে: %1$s সব অ্যাপ সফলভাবে আপডেট হয়েছে কোনো আপডেট পাওয়া যায়নি আপনার অ্যাপ খুঁজুন কোনো অ্যাপ পাওয়া যায়নি সব আপডেট করুন আপডেট খুলুন বাতিল পরীক্ষা করা হচ্ছে… সফলভাবে আপডেট হয়েছে ত্রুটি: %1$s %2$d এর মধ্যে %1$d টি আপডেট করা হচ্ছে বর্তমানে: %1$s অনুমোদনের জন্য অপেক্ষা করা হচ্ছে… সাইন ইন সম্পন্ন! আপনি এখন অ্যাপটি ব্যবহার করতে পারেন। রিডাইরেক্ট করা হচ্ছে… আবার চেষ্টা করুন ত্রুটি: %1$s GitHub-এ এই কোডটি লিখুন: কোড কপি করুন GitHub খুলুন সম্পূর্ণ অভিজ্ঞতা\nআনলক করুন আরও অনুরোধ বেশি API রেট লিমিট এবং বিঘ্ন থেকে মুক্তি পেতে সাইন ইন করুন। GitHub দিয়ে সাইন ইন করুন বাতিল করা হয়েছে অজানা ত্রুটি ভাষা: রিপোজিটরি আবিষ্কার করুন রিপো, বিবরণ খুঁজুন… ভাষা অনুযায়ী ফিল্টার করুন %1$d টি ফলাফল পাওয়া গেছে সাজান বন্ধ করুন সবচেয়ে বেশি স্টার সবচেয়ে বেশি ফর্ক সেরা মিল অধোগামী ঊর্ধ্বগামী সাজান সব প্রোগ্রামিং ভাষা Kotlin Java JavaScript TypeScript Python Swift Rust Go C# C++ C Dart Ruby PHP অনুসন্ধান ব্যর্থ হয়েছে কোনো রিপোজিটরি পাওয়া যায়নি প্রোফাইল চেহারা সম্পর্কে নেটওয়ার্ক থিমের রঙ AMOLED কালো থিম অন্ধকার মোডের জন্য সম্পূর্ণ কালো ব্যাকগ্রাউন্ড নির্বাচিত রঙ: %1$s সংস্করণ সহায়তা ও সাপোর্ট লগআউট সফলভাবে লগআউট হয়েছে, রিডাইরেক্ট করা হচ্ছে... ক্যাশ সফলভাবে পরিষ্কার করা হয়েছে সতর্কতা! আপনি কি নিশ্চিতভাবে লগআউট করতে চান? ডাইনামিক সমুদ্র বেগুনি বন স্লেট অ্যাম্বার রিপোজিটরি খুলুন ব্রাউজারে খুলুন ডাউনলোড বাতিল করুন ইনস্টল অপশন দেখান বিস্তারিত লোড করতে ত্রুটি দেখা গেছে পুনরায় চেষ্টা করুন কোনো বিবরণ দেওয়া হয়নি। কোনো রিলিজ নোট নেই। সমস্যা রিপোর্ট করুন এই অ্যাপ বৃত্তান্ত ইনস্টল লগ লেখক নতুন কী আছে ইনস্টল করা আপডেট উপলব্ধ উপলব্ধ নয় সর্বশেষটি ইনস্টল করুন পুনরায় ইনস্টল করুন অ্যাপ আপডেট করুন ডাউনলোড হচ্ছে আপডেট হচ্ছে যাচাই করা হচ্ছে ইনস্টল হচ্ছে Obtainium-এ খুলুন স্বয়ংক্রিয়ভাবে আপডেট পরিচালনা করুন AppManager দিয়ে পরীক্ষা করুন অনুমতি, ট্র্যাকার ও নিরাপত্তা যাচাই করুন প্রোফাইল ফর্ক স্টার ইস্যু %1$s দ্বারা • ইনস্টল করা: %1$s আর্কিটেকচার উপযোগী %1$s -এ আপডেট করুন বিস্তারিত লোড করতে ব্যর্থ ইনস্টলারটি Downloads ফোল্ডারে সংরক্ষিত হয়েছে ডাউনলোড শুরু হয়েছে ডাউনলোড সম্পন্ন আপডেট শুরু হয়েছে ইনস্টল হয়েছে আপডেট হয়েছে বাতিল হয়েছে ইনস্টলেশন শুরু হয়েছে ত্রুটি ত্রুটি: %1$s AppManager-এর জন্য প্রস্তুত করা হচ্ছে AppManager-এ খোলা হয়েছে ডিভাইস নীতি দ্বারা ইনস্টল অনুমতি অবরুদ্ধ বাহ্যিক ইনস্টলারে খোলা হয়েছে ইনস্টল অনুমতি অনুপলব্ধ APK সফলভাবে ডাউনলোড হয়েছে কিন্তু এই ডিভাইসে সরাসরি ইনস্টল করার অনুমতি নেই। আপনি কি এটি বাহ্যিক ইনস্টলার দিয়ে খুলতে চান? বাহ্যিক ইনস্টলার দিয়ে খুলুন APK ইনস্টল করতে তৃতীয় পক্ষের অ্যাপ ব্যবহার করুন ত্রুটি: %1$s .%1$s অ্যাসেট টাইপ সমর্থিত নয় ডাউনলোড করা ফাইল পাওয়া যায়নি ট্রেন্ডিং হট রিলিজ সবচেয়ে জনপ্রিয় রিপোজিটরি খোঁজা হচ্ছে... আরও লোড হচ্ছে... আর কোনো রিপোজিটরি নেই পুনরায় চেষ্টা রিপোজিটরি লোড করতে ব্যর্থ বিস্তারিত দেখুন এইমাত্র আপডেট হয়েছে %1$d ঘণ্টা আগে আপডেট হয়েছে গতকাল আপডেট হয়েছে %1$d দিন আগে আপডেট হয়েছে %1$s তারিখে আপডেট হয়েছে রেট লিমিট অতিক্রম করেছে আপনি সব %1$d টি API অনুরোধ ব্যবহার করেছেন। আপনি সব %1$d টি ফ্রি API অনুরোধ ব্যবহার করেছেন। %1$d মিনিটে রিসেট হবে 💡 ৬০ এর বদলে প্রতি ঘণ্টায় ৫,০০০ অনুরোধ পেতে সাইন ইন করুন! সাইন ইন ঠিক আছে বন্ধ করুন সিস্টেম ফন্ট আরও ভালো পাঠযোগ্যতার জন্য ডিভাইসের ফন্ট ব্যবহার করুন উজ্জ্বল অন্ধকার সিস্টেম রিপোজিটরি প্রিয় তালিকায় যোগ করা হয়েছে রিপোজিটরি প্রিয় তালিকা থেকে সরানো হয়েছে প্রিয় তালিকায় যোগ করুন প্রিয় তালিকা থেকে সরান প্রিয় এইমাত্র যোগ করা হয়েছে %1$d ঘণ্টা আগে যোগ করা হয়েছে গতকাল যোগ করা হয়েছে %1$d দিন আগে যোগ করা হয়েছে %1$s তারিখে যোগ করা হয়েছে স্টার করা রিপোজিটরি রিপোজিটরি স্টার করা হয়েছে রিপোজিটরি স্টার করা হয়নি আপনি GitHub থেকে রিপোজিটরি স্টার করতে পারেন আপনি GitHub থেকে রিপোজিটরির স্টার সরাতে পারেন সাইন ইন প্রয়োজন স্টার করা রিপোজিটরি দেখতে GitHub দিয়ে সাইন ইন করুন কোনো স্টার করা রিপোজিটরি নেই ইনস্টলযোগ্য রিলিজ থাকা রিপোজিটরি GitHub-এ স্টার করুন শেষ সিঙ্ক এইমাত্র %1$d মিনিট আগে %1$d ঘণ্টা আগে %1$d দিন আগে বন্ধ করুন স্টার করা রিপোজিটরি সিঙ্ক করতে ব্যর্থ হয়েছে ডেভেলপার প্রোফাইল ডেভেলপার প্রোফাইল খুলুন রিপোজিটরি লোড করতে ব্যর্থ প্রোফাইল লোড করতে ব্যর্থ রিপোজিটরি অনুসরণকারী অনুসরণ রিপোজিটরি খুঁজুন… অনুসন্ধান মুছুন সব রিলিজ সহ ইনস্টল করা পছন্দ সাজান সাম্প্রতিক আপডেট নাম রিপোজিটরি রিপোজিটরি %2$d টির মধ্যে %1$d টি রিপোজিটরি দেখানো হচ্ছে ইনস্টল করার যোগ্য রিলিজ সহ কোনো রিপোজিটরি নেই কোনো রিপোজিটরি ইনস্টল করা হয়নি কোনো পছন্দের রিপোজিটরি নেই %1$s আপডেট করা হয়েছে রিলিজ আছে %1$d বছর আগে %1$d মাস আগে %1$d দিন আগে %1$d ঘণ্টা আগে %1$d মিনিট আগে %1$dM %1$dk এইমাত্র প্রকাশিত %1$d ঘণ্টা আগে প্রকাশিত গতকাল প্রকাশিত %1$d দিন আগে প্রকাশিত %1$s তারিখে প্রকাশিত হোম অনুসন্ধান অ্যাপস প্রোফাইল ফর্ক স্থিতিশীল প্রি-রিলিজ সব ভার্সন নির্বাচন করুন প্রি-রিলিজ কোনো ভার্সন নির্বাচিত নয় ভার্সনসমূহ ইনস্টল মুলতুবি আনইনস্টল খুলুন ডাউনগ্রেডের জন্য আনইনস্টল প্রয়োজন সংস্করণ %1$s ইনস্টল করতে বর্তমান সংস্করণ (%2$s) প্রথমে আনইনস্টল করতে হবে। অ্যাপের ডেটা মুছে যাবে। প্রথমে আনইনস্টল করুন %1$s ইনস্টল করুন %1$s খুলতে ব্যর্থ %1$s আনইনস্টল করতে ব্যর্থ সর্বশেষ সর্বশেষ পরীক্ষা: %1$s কখনো পরীক্ষা করা হয়নি এইমাত্র %1$d মিনিট আগে %1$d ঘণ্টা আগে আপডেট পরীক্ষা করা হচ্ছে… প্রক্সি ধরন নেই সিস্টেম HTTP SOCKS হোস্ট পোর্ট ব্যবহারকারীর নাম (ঐচ্ছিক) পাসওয়ার্ড (ঐচ্ছিক) প্রক্সি সংরক্ষণ প্রক্সি সেটিংস সংরক্ষিত হয়েছে আপনার ডিভাইসের প্রক্সি সেটিংস ব্যবহার করে পোর্ট ১–৬৫৫৩৫ এর মধ্যে হতে হবে সরাসরি সংযোগ, কোনো প্রক্সি নেই প্রক্সি সেটিংস সংরক্ষণ করতে ব্যর্থ হয়েছে প্রক্সি হোস্ট প্রয়োজন অবৈধ প্রক্সি পোর্ট পাসওয়ার্ড দেখান পাসওয়ার্ড লুকান এই অ্যাপ ট্র্যাক করুন অ্যাপ ট্র্যাকিং তালিকায় যোগ করা হয়েছে অ্যাপ ট্র্যাক করতে ব্যর্থ: %1$s অ্যাপটি ইতিমধ্যে ট্র্যাক করা হচ্ছে GitHub-এ সাইন ইন করুন সম্পূর্ণ অভিজ্ঞতা আনলক করুন। আপনার অ্যাপ পরিচালনা করুন, পছন্দ সিঙ্ক করুন এবং দ্রুত ব্রাউজ করুন। রিপোজিটরি লগইন GitHub-এ আপনার স্টার করা রিপোজিটরি স্থানীয়ভাবে সংরক্ষিত আপনার প্রিয় রিপোজিটরি সেশনের মেয়াদ শেষ আপনার GitHub সেশনের মেয়াদ শেষ হয়ে গেছে বা টোকেনটি প্রত্যাহার করা হয়েছে। প্রমাণিত বৈশিষ্ট্যগুলি ব্যবহার চালিয়ে যেতে অনুগ্রহ করে আবার সাইন ইন করুন। আপনি সীমিত API অনুরোধ সহ অতিথি হিসেবে ব্রাউজ করতে পারেন। আবার সাইন ইন করুন অতিথি হিসেবে চালিয়ে যান এটি আপনার স্থানীয় সেশন এবং ক্যাশ ডেটা মুছে ফেলবে। সম্পূর্ণরূপে অ্যাক্সেস প্রত্যাহার করতে, GitHub Settings > Applications এ যান। কোডের মেয়াদ শেষ হবে %1$s এ ডিভাইস কোডের মেয়াদ শেষ হয়ে গেছে। একটি নতুন কোড পেতে অনুগ্রহ করে আবার সাইন ইন করার চেষ্টা করুন। অনুগ্রহ করে আপনার ইন্টারনেট সংযোগ পরীক্ষা করুন এবং আবার চেষ্টা করুন। আপনি অনুমোদনের অনুরোধ প্রত্যাখ্যান করেছেন। এটি অনিচ্ছাকৃত হলে আবার চেষ্টা করুন। আরও পড়ুন কম দেখান রিপোজিটরি শেয়ার করুন লিংক শেয়ার করতে ব্যর্থ হয়েছে লিংক ক্লিপবোর্ডে কপি করা হয়েছে অনুবাদ করুন অনুবাদ হচ্ছে… মূল দেখান %1$s এ অনুবাদিত অনুবাদ করুন… ভাষা খুঁজুন ভাষা পরিবর্তন করুন অনুবাদ ব্যর্থ হয়েছে। আবার চেষ্টা করুন। GitHub লিংক খুলুন ক্লিপবোর্ডে GitHub লিংক পাওয়া গেছে ক্লিপবোর্ড লিংক স্বয়ংক্রিয় সনাক্তকরণ অনুসন্ধান খোলার সময় স্বয়ংক্রিয়ভাবে ক্লিপবোর্ড থেকে GitHub লিংক সনাক্ত করুন সনাক্তকৃত লিংক অ্যাপে খুলুন ক্লিপবোর্ডে কোনো GitHub লিংক পাওয়া যায়নি স্টোরেজ ক্যাশে পরিষ্কার করুন বর্তমান আকার: পরিষ্কার করুন GitHub Store সমর্থন করুন প্রকল্পকে সমর্থন করুন ভালোবাসা দিয়ে তৈরি,\nকফি দিয়ে চালিত GitHub Store 130,000+ ডাউনলোড এবং 7,700+ GitHub স্টার অর্জন করেছে — ১০০% ফ্রি, কোনো বিজ্ঞাপন নেই, কোনো ট্র্যাকিং নেই। আমি উচ্চ বিদ্যালয় শেষ করার সময় একাই এই প্রকল্পটি তৈরি ও রক্ষণাবেক্ষণ করছি। আপনার সমর্থন — ছোট হলেও — অ্যাপটিকে বাগমুক্ত রাখতে, অবকাঠামো খরচ চালাতে এবং নতুন ফিচার আনতে সাহায্য করে। GitHub Store এর জন্য ভোট দিন! GitHub Store KotlinConf 2026 এর Golden Kodee Awards এর জন্য মনোনীত হয়েছে। 1. নিবন্ধন করুন 2. ভোট দিন ভোট ২২ মার্চ পর্যন্ত 1. পুরস্কার প্ল্যাটফর্মে নিবন্ধন করুন (Google দিয়ে চালিয়ে যান) 2. নিচে Vote চাপুন 3. Usmon Narzullayev খুঁজে Vote চাপুন GitHub Sponsors GitHub এর মাধ্যমে একবার বা নিয়মিত সমর্থন Buy Me a Coffee দ্রুত একবারের সমর্থন সহায়তার অন্যান্য উপায় রিপোজিটরিতে স্টার দিন অন্যদের GitHub Store খুঁজে পেতে সাহায্য করে বাগ রিপোর্ট করুন অ্যাপটিকে আরও ভালো করে বন্ধুদের সাথে শেয়ার করুন অন্যান্য ডেভেলপারদের জানাতে সাহায্য করে যে কোনো সমর্থন — অর্থনৈতিক হোক বা না হোক — এই প্রকল্পটিকে বাঁচিয়ে রাখে। ধন্যবাদ! ইনস্টলেশন ডিফল্ট স্ট্যান্ডার্ড সিস্টেম ইনস্টল ডায়ালগ Shizuku প্রম্পট ছাড়া নীরব ইনস্টল Shizuku ইনস্টল করা নেই Shizuku চলছে না অনুমতি প্রয়োজন প্রস্তুত অনুমতি দিন নীরব ইনস্টল সক্রিয় করতে Shizuku ইনস্টল করুন নীরব ইনস্টল সক্রিয় করতে Shizuku চালু করুন Shizuku ইনস্টল ব্যর্থ, স্ট্যান্ডার্ড ইনস্টলার ব্যবহার করা হচ্ছে স্বয়ংক্রিয়ভাবে অ্যাপ আপডেট করুন Shizuku এর মাধ্যমে ব্যাকগ্রাউন্ডে স্বয়ংক্রিয়ভাবে আপডেট ডাউনলোড এবং ইনস্টল করুন আপডেট আপডেট চেক করার ব্যবধান ব্যাকগ্রাউন্ডে কতক্ষণ পর পর অ্যাপ আপডেট খোঁজা হবে ৩ঘ ৬ঘ ১২ঘ ২৪ঘ লিঙ্ক দিয়ে যোগ করুন অ্যাপ রিপোজিটরিতে লিঙ্ক করুন GitHub রিপোজিটরিতে লিঙ্ক করতে একটি ইনস্টল করা অ্যাপ বেছে নিন অ্যাপ খুঁজুন… GitHub রিপোজিটরি URL github.com/owner/repo যাচাই হচ্ছে… লিঙ্ক এবং ট্র্যাক করুন সর্বশেষ রিলিজ পরীক্ষা হচ্ছে… যাচাইয়ের জন্য APK ডাউনলোড হচ্ছে… সাইনিং কী যাচাই হচ্ছে… প্যাকেজ নাম মেলেনি: APK হলো %1$s, কিন্তু নির্বাচিত অ্যাপ হলো %2$s সাইনিং কী মেলেনি: এই রিপোজিটরির APK একজন ভিন্ন ডেভেলপার দ্বারা স্বাক্ষরিত ইনস্টলার নির্বাচন করুন আপনার ইনস্টল করা অ্যাপের সাথে যাচাই করতে APK নির্বাচন করুন ডাউনলোড ব্যর্থ রপ্তানি আমদানি অ্যাপ আমদানি করুন ট্র্যাক করা অ্যাপ পুনরুদ্ধার করতে রপ্তানি করা JSON পেস্ট করুন রপ্তানি করা JSON এখানে পেস্ট করুন… প্রি-রিলিজ অন্তর্ভুক্ত করুন আপডেট পরীক্ষার সময় প্রি-রিলিজ সংস্করণ ট্র্যাক করুন। নিষ্ক্রিয় থাকলে, শুধুমাত্র স্থিতিশীল রিলিজ বিবেচনা করা হয়। অ্যাপ আনইনস্টল করবেন? আপনি কি নিশ্চিত যে %1$s আনইনস্টল করতে চান? এই ক্রিয়া পূর্বাবস্থায় ফেরানো যাবে না এবং অ্যাপের ডেটা হারিয়ে যেতে পারে। অবৈধ GitHub URL। ফর্ম্যাট ব্যবহার করুন: github.com/owner/repo রিপোজিটরি পাওয়া যায়নি: %1$s/%2$s GitHub API হার সীমা অতিক্রম করেছে। পরে আবার চেষ্টা করুন। লিঙ্ক করতে ব্যর্থ: %1$s ইনস্টল করা অ্যাপ লোড করতে ব্যর্থ %1$s %2$s/%3$s এর সাথে লিঙ্ক করা হয়েছে রপ্তানি ব্যর্থ: %1$s আমদানি ব্যর্থ: %1$s %1$d অ্যাপ আমদানি করা হয়েছে , %1$d বাদ দেওয়া হয়েছে , %1$d ব্যর্থ সাইনিং কী পরিবর্তিত হয়েছে এই অ্যাপের সাইনিং সার্টিফিকেট প্রথম ইনস্টলের পর থেকে পরিবর্তিত হয়েছে।\n\nএর অর্থ হতে পারে ডেভেলপার তাদের সাইনিং কী পরিবর্তন করেছে, অথবা বাইনারি পরিবর্তন করা হয়েছে।\n\nপ্রত্যাশিত: %1$s\nপ্রাপ্ত: %2$s যাই হোক ইনস্টল করুন যাচাইকৃত বিল্ড পরীক্ষা হচ্ছে\u2026 সম্পদ কোনো সম্পদ নেই এই রিলিজের সাথে কোনো সম্পদ যুক্ত নেই সম্পদ বিকল্প নির্বাচন করুন একাধিক সম্পদ উপলব্ধ এই রিলিজের জন্য একাধিক ইনস্টলযোগ্য ফাইল উপলব্ধ। তালিকা পর্যালোচনা করুন এবং আপনার ডিভাইসের জন্য উপযুক্তটি নির্বাচন করুন। তথ্য পুনরায় চেষ্টা স্বয়ংক্রিয়ভাবে শনাক্ত: %1$s ভাষা নির্বাচন করুন প্যাকেজ অমিল: APK হলো %1$s, কিন্তু ইনস্টল করা অ্যাপ হলো %2$s। আপডেট ব্লক করা হয়েছে। সাইনিং কী অমিল: আপডেটটি একজন ভিন্ন ডেভেলপার দ্বারা সাইন করা হয়েছে। আপডেট ব্লক করা হয়েছে। লিকুইড গ্লাস ইফেক্ট একটি মসৃণ কাচের মতো চেহারা দিয়ে ইন্টারফেস উন্নত করুন দেখা রিপোজিটরি লুকান আপনি ইতিমধ্যে দেখেছেন এমন রিপোজিটরি আবিষ্কার ফিড থেকে লুকান দেখার ইতিহাস মুছুন সমস্ত দেখা রিপোজিটরি রিসেট করুন যাতে সেগুলো ফিডে আবার দেখা যায় দেখার ইতিহাস মুছে ফেলা হয়েছে দেখা হয়েছে ================================================ FILE: core/presentation/src/commonMain/composeResources/values-es/strings-es.xml ================================================ GitHub Store Aplicaciones instaladas Volver Buscar actualizaciones No se puede iniciar %1$s No se pudo abrir %1$s Error al actualizar %1$s: %2$s Error en la actualización Error al actualizar todo: %1$s Todas las aplicaciones se actualizaron correctamente No hay actualizaciones disponibles Buscar aplicaciones No se encontraron aplicaciones Actualizar todo Actualizar Abrir Cancelar Comprobando… Actualizado correctamente Error: %1$s Actualizando %1$d de %2$d Actual: %1$s Esperando autorización… ¡Sesión iniciada! Ya puedes usar la aplicación. Redirigiendo… Intentar de nuevo Error: %1$s Introduce este código en GitHub: Copiar código Abrir GitHub Desbloquea la experiencia\ncompleta Más solicitudes Inicia sesión para obtener límites de API más altos y evitar interrupciones. Iniciar sesión con GitHub Cancelado Error desconocido Idioma: Descubrir repositorios Buscar repositorio, descripción… Filtrar por lenguaje %1$d resultados encontrados Reintentar Ordenar por Cerrar Más estrellas Más forks Mejor coincidencia Descendente Ascendente Ordenar Todos los lenguajes Kotlin Java JavaScript TypeScript Python Swift Rust Go C# C++ C Dart Ruby PHP La búsqueda falló No se encontraron repositorios Perfil APARIENCIA ACERCA DE RED Color del tema Tema negro AMOLED Fondo negro puro para modo oscuro Color seleccionado: %1$s Versión Ayuda y soporte Cerrar sesión Sesión cerrada correctamente, redirigiendo… Caché borrada con éxito ¡Advertencia! ¿Estás seguro de que deseas cerrar sesión? Dinámico Océano Púrpura Bosque Pizarra Ámbar Error al cargar detalles Acerca de esta app Registros de instalación Autor Novedades Instalado Actualización disponible Instalar última versión Reinstalar Descargando Actualizando Verificando Instalando Perfil Bifurcaciones Estrellas Problemas por %1$s • Instalado: %1$s Arquitectura compatible Actualizar a %1$s No se pudieron cargar los detalles El instalador se guardó en Descargas Descarga iniciada Descargado Actualización iniciada Instalado Actualizado Cancelado Instalación iniciada Error Error: %1$s Preparando para AppManager Abierto en AppManager Permiso de instalación bloqueado por política del dispositivo Abierto en instalador externo Permiso de instalación no disponible El APK se descargó correctamente pero este dispositivo no permite la instalación directa. ¿Desea abrirlo con un instalador externo? Abrir con instalador externo Usar una aplicación de terceros para instalar el APK Error: %1$s Tipo de archivo .%1$s no compatible Archivo descargado no encontrado Tendencias Lanzamiento en caliente Los más populares Buscando repositorios... Cargando... No hay más repositorios Reintentar No se pudieron cargar los repositorios Ver detalles actualizado ahora mismo actualizado hace %1$d h actualizado ayer actualizado hace %1$d días actualizado el %1$s Límite de solicitudes excedido Has usado las %1$d solicitudes de la API. Has usado las %1$d solicitudes gratuitas de la API. Se restablece en %1$d minutos 💡 Inicia sesión para obtener 5 000 solicitudes por hora en lugar de 60. Iniciar sesión OK Cerrar Fuente del sistema Usa la fuente de tu dispositivo para mejor legibilidad Claro Oscuro Sistema Repositorio añadido a favoritos Repositorio eliminado de favoritos Añadir a favoritos Quitar de favoritos Favoritos añadido justo ahora añadido hace %1$d hora(s) añadido ayer añadido hace %1$d día(s) añadido el %1$s Repositorios destacados El repositorio está marcado con estrella El repositorio no está marcado con estrella Puedes marcar el repositorio desde GitHub Puedes quitar la estrella desde GitHub Inicio de sesión requerido Inicia sesión con GitHub para ver tus repositorios destacados No hay repositorios destacados Marca repositorios con lanzamientos instalables en GitHub para verlos aquí Última sincronización Justo ahora Hace %d min Hace %d h Hace %d d Cerrar No se pudieron sincronizar los repositorios destacados Perfil del desarrollador Abrir perfil de desarrollador Error al cargar repositorios Error al cargar perfil Repositorios Seguidores Siguiendo Buscar repositorios… Borrar búsqueda Todos Con lanzamientos Instalados Favoritos Ordenar Actualizados recientemente Nombre repositorio repositorios Mostrando %1$d de %2$d repositorios No hay repositorios con lanzamientos instalables No hay repositorios instalados No hay repositorios favoritos Actualizado hace %1$s Tiene lanzamiento hace %1$d año(s) hace %1$d mes(es) hace %1$d d hace %1$d h hace %1$d min %1$dM %1$dk Publicado ahora mismo Publicado hace %1$d hora(s) Publicado ayer Publicado hace %1$d día(s) Publicado el %1$s Inicio Buscar Aplicaciones Perfil Bifurcar Estable Prelanzamiento Todos Seleccionar versión Prelanzamiento Ninguna versión seleccionada Versiones Abrir repositorio Abrir en navegador Cancelar descarga Mostrar opciones de instalación Sin descripción proporcionada. Sin notas de versión. Reportar problema No disponible Actualizar app Instalación pendiente Abrir en Obtainium Gestionar actualizaciones automáticamente Inspeccionar con AppManager Verificar permisos, rastreadores y seguridad Desinstalar Abrir La degradación requiere desinstalar Instalar la versión %1$s requiere desinstalar la versión actual (%2$s) primero. Los datos de la app se perderán. Desinstalar primero Instalar %1$s Error al abrir %1$s Error al desinstalar %1$s Última Última comprobación: %1$s Nunca comprobado justo ahora hace %1$d min hace %1$d h Comprobando actualizaciones… Tipo de proxy Ninguno Sistema HTTP SOCKS Host Puerto Nombre de usuario (opcional) Contraseña (opcional) Guardar proxy Configuración de proxy guardada Usa la configuración de proxy del dispositivo El puerto debe ser 1–65535 Conexión directa, sin proxy No se pudieron guardar los ajustes del proxy Se requiere el host del proxy Puerto de proxy no válido Mostrar contraseña Ocultar contraseña Rastrear esta app App añadida a la lista de seguimiento Error al rastrear la app: %1$s La app ya está siendo rastreada Iniciar sesión en GitHub Desbloquea la experiencia completa. Gestiona tus apps, sincroniza tus preferencias y navega más rápido. Repos Iniciar sesión Tus repositorios destacados de GitHub Tus repositorios favoritos guardados localmente Sesión expirada Tu sesión de GitHub ha expirado o el token fue revocado. Inicia sesión nuevamente para continuar usando las funciones autenticadas. Puedes seguir navegando como invitado con solicitudes de API limitadas. Iniciar sesión de nuevo Continuar como invitado Esto borrará tu sesión local y los datos en caché. Para revocar el acceso completamente, visita GitHub Settings > Applications. El código expira en %1$s El código del dispositivo ha expirado. Intenta iniciar sesión de nuevo para obtener un nuevo código. Revisa tu conexión a internet e intenta de nuevo. Rechazaste la solicitud de autorización. Intenta de nuevo si fue involuntario. Leer más Mostrar menos Compartir repositorio No se pudo compartir el enlace Enlace copiado al portapapeles Traducir Traduciendo… Mostrar original Traducido a %1$s Traducir a… Buscar idioma Cambiar idioma Error de traducción. Inténtalo de nuevo. Abrir enlace de GitHub Enlace de GitHub detectado en el portapapeles Detectar enlaces del portapapeles Detectar automáticamente enlaces de GitHub del portapapeles al abrir la búsqueda Enlaces detectados Abrir en la app No se encontró enlace de GitHub en el portapapeles Almacenamiento Borrar caché Tamaño actual: Borrar Apoya GitHub Store Apoyar el proyecto Creado con amor,\nmantenido con café GitHub Store ha alcanzado más de 130,000 descargas y 7,700 estrellas en GitHub — 100% gratis, sin anuncios ni rastreo. Construí y mantengo esto completamente por mi cuenta mientras termino la secundaria. Tu apoyo — incluso una pequeña cantidad — ayuda a mantener la app sin errores, pagar la infraestructura y lanzar las funciones que solicitan. ¡Vota por GitHub Store! GitHub Store está nominado a los Golden Kodee Awards en KotlinConf 2026. Tu voto toma solo 2 minutos y significa mucho. 1. Registrarse 2. Votar La votación cierra el 22 de marzo 1. Regístrate en la plataforma de premios (Continuar con Google) 2. Toca Votar abajo para abrir la página 3. Busca a Usmon Narzullayev y pulsa Votar GitHub Sponsors Apoyo recurrente o único mediante GitHub Buy Me a Coffee Apoyo rápido de una sola vez OTRAS FORMAS DE AYUDAR Dar estrella al repositorio Ayuda a otros a descubrir GitHub Store Reportar errores Mejora la app para todos Compartir con amigos Corre la voz entre otros desarrolladores Cada forma de apoyo — financiera o no — mantiene vivo este proyecto. ¡Gracias! Instalación Predeterminado Diálogo de instalación estándar del sistema Shizuku Instalación silenciosa sin confirmaciones Shizuku no está instalado Shizuku no está en ejecución Permiso requerido Listo Conceder permiso Instala Shizuku para habilitar la instalación silenciosa Inicia Shizuku para habilitar la instalación silenciosa La instalación con Shizuku falló, usando el instalador estándar Actualizar apps automáticamente Descargar e instalar actualizaciones en segundo plano a través de Shizuku Actualizaciones Intervalo de verificación Con qué frecuencia buscar actualizaciones en segundo plano 3h 6h 12h 24h Añadir por enlace Vincular app al repositorio Elige una app instalada para vincularla a un repositorio de GitHub Buscar apps… URL del repositorio GitHub github.com/owner/repo Validando… Vincular y seguir Comprobando último lanzamiento… Descargando APK para verificación… Verificando clave de firma… Nombre de paquete no coincide: el APK es %1$s, pero la app seleccionada es %2$s Clave de firma no coincide: el APK de este repositorio fue firmado por un desarrollador diferente Seleccionar instalador Elige el APK para verificar contra tu app instalada Error en la descarga Exportar Importar Importar apps Pega el JSON exportado para restaurar tus apps rastreadas Pega el JSON exportado aquí… Incluir pre-lanzamientos Rastrear versiones pre-lanzamiento al buscar actualizaciones. Si está desactivado, solo se consideran las versiones estables. ¿Desinstalar app? ¿Estás seguro de que quieres desinstalar %1$s? Esta acción no se puede deshacer y los datos de la app podrían perderse. URL de GitHub no válida. Usa el formato: github.com/owner/repo Repositorio no encontrado: %1$s/%2$s Límite de la API de GitHub excedido. Inténtalo más tarde. Error al vincular: %1$s Error al cargar las apps instaladas %1$s vinculada a %2$s/%3$s Error en la exportación: %1$s Error en la importación: %1$s %1$d apps importadas , %1$d omitidas , %1$d fallidas Clave de firma cambiada El certificado de firma de esta app ha cambiado desde su primera instalación.\n\nEsto podría significar que el desarrollador rotó su clave de firma, o el binario pudo haber sido manipulado.\n\nEsperado: %1$s\nRecibido: %2$s Instalar de todos modos Compilación verificada Comprobando\u2026 Recursos Sin recursos No hay recursos asociados a este lanzamiento Seleccionar opción de recurso Múltiples recursos disponibles Hay varios archivos instalables disponibles para este lanzamiento. Revisa la lista y selecciona el que se ajuste a tu dispositivo. Información Reintentar Detectado automáticamente: %1$s Seleccionar idioma Paquete no coincide: el APK es %1$s, pero la aplicación instalada es %2$s. Actualización bloqueada. Clave de firma no coincide: la actualización fue firmada por un desarrollador diferente. Actualización bloqueada. Efecto de cristal líquido Mejora la interfaz con una apariencia suave tipo cristal Ocultar repositorios vistos Ocultar repositorios que ya has visto de las fuentes de descubrimiento Borrar historial de vistos Restablecer todos los repositorios vistos para que aparezcan de nuevo en las fuentes Historial de vistos borrado Visto ================================================ FILE: core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml ================================================ GitHub Store Applications installées Retour Vérifier les mises à jour Impossible de lancer %1$s Impossible d’ouvrir %1$s Échec de la mise à jour de %1$s : %2$s Échec de la mise à jour Échec de la mise à jour globale : %1$s Toutes les applications ont été mises à jour Aucune mise à jour disponible Rechercher des applications Aucune application trouvée Tout mettre à jour Mettre à jour Ouvrir Annuler Vérification… Mise à jour réussie Erreur : %1$s Mise à jour %1$d sur %2$d Actuellement : %1$s En attente d’autorisation… Connecté ! Vous pouvez maintenant utiliser l’application. Redirection… Réessayer Erreur : %1$s Entrez ce code sur GitHub : Copier le code Ouvrir GitHub Débloquez l’expérience\ncomplète Plus de requêtes Connectez-vous pour obtenir des limites API plus élevées et éviter les interruptions. Se connecter avec GitHub Annulé Erreur inconnue Langue : Découvrir des dépôts Rechercher dépôt, description… Filtrer par langage %1$d résultats trouvés Réessayer Trier par Fermer Le plus d’étoiles Le plus de forks Meilleure correspondance Décroissant Croissant Trier Tous les langages Kotlin Java JavaScript TypeScript Python Swift Rust Go C# C++ C Dart Ruby PHP La recherche a échoué Aucun dépôt trouvé Profil APPARENCE À PROPOS RÉSEAU Couleur du thème Thème noir AMOLED Fond noir pur pour le mode sombre Couleur sélectionnée : %1$s Version Aide et support Se déconnecter Déconnexion réussie, redirection… Cache vidé avec succès Attention ! Voulez-vous vraiment vous déconnecter ? Dynamique Océan Violet Forêt Ardoise Ambre Erreur de chargement À propos de cette application Journaux d’installation Auteur Nouveautés Installé Mise à jour disponible Installer la dernière version Réinstaller Téléchargement Mise à jour Vérification Installation Profil Forks Étoiles Tickets par %1$s • Installé : %1$s Architecture compatible Mettre à jour vers %1$s Impossible de charger les détails L’installateur a été enregistré dans Téléchargements Téléchargement démarré Téléchargé Mise à jour démarrée Installé Mis à jour Annulé Installation démarrée Erreur Erreur : %1$s Préparation pour AppManager Ouvert dans AppManager Permission d\'installation bloquée par la politique de l\'appareil Ouvert dans l\'installateur externe Permission d\'installation indisponible L\'APK a été téléchargé avec succès mais cet appareil n\'autorise pas l\'installation directe. Voulez-vous l\'ouvrir avec un installateur externe ? Ouvrir avec un installateur externe Utiliser une application tierce pour installer l\'APK Erreur : %1$s Type de fichier .%1$s non pris en charge Fichier téléchargé introuvable Tendances Sortie en salles Les plus populaires Recherche de dépôts... Chargement... Plus aucun dépôt Réessayer Impossible de charger les dépôts Voir les détails mis à jour à l’instant mis à jour il y a %1$d h mis à jour hier mis à jour il y a %1$d j mis à jour le %1$s Limite de requêtes dépassée Vous avez utilisé les %1$d requêtes API. Vous avez utilisé les %1$d requêtes API gratuites. Réinitialisation dans %1$d minutes 💡 Connectez-vous pour obtenir 5 000 requêtes par heure au lieu de 60 ! Se connecter OK Fermer Police système Utilisez la police de votre appareil pour une meilleure lisibilité Clair Sombre Système Dépôt ajouté aux favoris Dépôt supprimé des favoris Ajouter aux favoris Retirer des favoris Favoris ajouté à l’instant ajouté il y a %1$d heure(s) ajouté hier ajouté il y a %1$d jour(s) ajouté le %1$s Dépôts Étoilés Le dépôt est en favori Le dépôt n’est pas en favori Vous pouvez ajouter ce dépôt aux favoris sur GitHub Vous pouvez retirer ce dépôt des favoris sur GitHub Connexion requise Aucun dépôt favori Connectez-vous avec GitHub pour voir vos dépôts favoris Ajoutez des dépôts avec des versions installables sur GitHub pour les voir ici Dernière synchronisation À l’instant Il y a %d min Il y a %d h Il y a %d j Fermer Échec de la synchronisation des dépôts favoris Profil du développeur Ouvrir le profil du développeur Échec du chargement des dépôts Échec du chargement du profil Dépôts Abonnés Abonnements Rechercher des dépôts… Effacer la recherche Tous Avec versions Installés Favoris Trier Récemment mis à jour Nom dépôt dépôts Affichage de %1$d sur %2$d dépôts Aucun dépôt avec des versions installables Aucun dépôt installé Aucun dépôt favori Signaler un problème Mis à jour %1$s A une version il y a %1$d an(s) il y a %1$d mois il y a %1$d j il y a %1$d h il y a %1$d min %1$dM %1$dk Publié à l’instant Publié il y a %1$d heure(s) Publié hier Publié il y a %1$d jour(s) Publié le %1$s Accueil Rechercher Applications Profil Fork Stable Préversion Tous Sélectionner une version Préversion Aucune version sélectionnée Versions Ouvrir le dépôt Ouvrir dans le navigateur Annuler le téléchargement Afficher les options d\'installation Aucune description fournie. Aucune note de version. Non disponible Mettre à jour Installation en attente Ouvrir dans Obtainium Gérer les mises à jour automatiquement Inspecter avec AppManager Vérifier les permissions, trackers et sécurité Désinstaller Ouvrir La rétrogradation nécessite la désinstallation L\'installation de la version %1$s nécessite la désinstallation de la version actuelle (%2$s). Les données de l\'application seront perdues. Désinstaller d\'abord Installer %1$s Impossible d\'ouvrir %1$s Impossible de désinstaller %1$s Dernière Dernière vérification : %1$s Jamais vérifié à l\'instant il y a %1$d min il y a %1$d h Vérification des mises à jour… Type de proxy Aucun Système HTTP SOCKS Hôte Port Nom d\'utilisateur (facultatif) Mot de passe (facultatif) Sauvegarder le Proxy Paramètres du proxy enregistrés Utilise les paramètres proxy de l\'appareil Le port doit être entre 1 et 65535 Connexion directe, pas de proxy Échec de l'enregistrement des paramètres du proxy L’hôte du proxy est requis Port proxy invalide Afficher le mot de passe Masquer le mot de passe Suivre cette app App ajoutée à la liste de suivi Échec du suivi de l\'app : %1$s L\'app est déjà suivie Se connecter à GitHub Débloquez l\'expérience complète. Gérez vos apps, synchronisez vos préférences et naviguez plus vite. Dépôts Connexion Vos dépôts étoilés sur GitHub Vos dépôts favoris enregistrés localement Session expirée Votre session GitHub a expiré ou le jeton a été révoqué. Veuillez vous reconnecter pour continuer à utiliser les fonctionnalités authentifiées. Vous pouvez toujours naviguer en tant qu\'invité avec des requêtes API limitées. Se reconnecter Continuer en tant qu\'invité Cela effacera votre session locale et les données en cache. Pour révoquer complètement l\'accès, visitez GitHub Settings > Applications. Le code expire dans %1$s Le code de l\'appareil a expiré. Veuillez réessayer de vous connecter pour obtenir un nouveau code. Vérifiez votre connexion internet et réessayez. Vous avez refusé la demande d\'autorisation. Réessayez si c\'était involontaire. Lire la suite Afficher moins Partager le dépôt Échec du partage du lien Lien copié dans le presse-papiers Traduire Traduction… Afficher l\'original Traduit en %1$s Traduire en… Rechercher une langue Changer de langue Échec de la traduction. Veuillez réessayer. Ouvrir le lien GitHub Lien GitHub détecté dans le presse-papiers Détecter les liens du presse-papiers Détecter automatiquement les liens GitHub du presse-papiers lors de l\'ouverture de la recherche Liens détectés Ouvrir dans l\'app Aucun lien GitHub trouvé dans le presse-papiers Stockage Vider le cache Taille actuelle : Vider Soutenir GitHub Store Soutenir le projet Créé avec amour,\nmaintenu avec du café GitHub Store a dépassé 130 000 téléchargements et 7 700 étoiles GitHub — 100 % gratuit, sans publicité ni suivi. J’ai construit et je maintiens ce projet seul tout en terminant le lycée. Votre soutien — même petit — aide à garder l’application sans bugs, payer l’infrastructure et livrer les fonctionnalités demandées. Votez pour GitHub Store ! GitHub Store est nommé aux Golden Kodee Awards de KotlinConf 2026. Votre vote prend seulement 2 minutes. 1. S’inscrire 2. Voter Vote jusqu’au 22 mars 1. Inscrivez-vous sur la plateforme (Continuer avec Google) 2. Appuyez sur Voter ci-dessous 3. Trouvez Usmon Narzullayev et cliquez sur Voter GitHub Sponsors Mensuel ou ponctuel via GitHub Buy Me a Coffee Soutien rapide en une fois AUTRES FAÇONS D’AIDER Mettre une étoile au dépôt Aide d’autres personnes à découvrir l’app Signaler des bugs Améliore l’application pour tous Partager avec des amis Parlez-en aux développeurs Chaque soutien — financier ou non — maintient ce projet en vie. Merci ! Installation Par défaut Boîte de dialogue d\'installation système standard Shizuku Installation silencieuse sans confirmation Shizuku n\'est pas installé Shizuku n\'est pas en cours d\'exécution Autorisation requise Prêt Accorder l\'autorisation Installez Shizuku pour activer l\'installation silencieuse Démarrez Shizuku pour activer l\'installation silencieuse L\'installation via Shizuku a échoué, utilisation de l\'installateur standard Mise à jour automatique Télécharger et installer automatiquement les mises à jour en arrière-plan via Shizuku Mises à jour Intervalle de vérification Fréquence de vérification des mises à jour en arrière-plan 3h 6h 12h 24h Ajouter par lien Lier l\'app au dépôt Choisissez une app installée à lier à un dépôt GitHub Rechercher des apps… URL du dépôt GitHub github.com/owner/repo Validation… Lier et suivre Vérification de la dernière version… Téléchargement de l\'APK pour vérification… Vérification de la clé de signature… Nom de paquet différent : l\'APK est %1$s, mais l\'app sélectionnée est %2$s Clé de signature différente : l\'APK de ce dépôt a été signé par un autre développeur Sélectionner l\'installateur Choisissez l\'APK à vérifier avec votre app installée Échec du téléchargement Exporter Importer Importer des apps Collez le JSON exporté pour restaurer vos apps suivies Collez le JSON exporté ici… Inclure les pré-versions Suivre les versions pré-release lors de la vérification des mises à jour. Désactivé, seules les versions stables sont prises en compte. Désinstaller l\'app ? Êtes-vous sûr de vouloir désinstaller %1$s ? Cette action est irréversible et les données de l\'app pourraient être perdues. URL GitHub invalide. Utilisez le format : github.com/owner/repo Dépôt introuvable : %1$s/%2$s Limite de l\'API GitHub dépassée. Réessayez plus tard. Échec de la liaison : %1$s Échec du chargement des apps installées %1$s liée à %2$s/%3$s Échec de l\'exportation : %1$s Échec de l\'importation : %1$s %1$d apps importées , %1$d ignorées , %1$d échouées Clé de signature modifiée Le certificat de signature de cette app a changé depuis sa première installation.\n\nCela peut signifier que le développeur a changé sa clé de signature, ou que le binaire a été altéré.\n\nAttendu : %1$s\nReçu : %2$s Installer quand même Build vérifié Vérification\u2026 Ressources Aucune ressource Aucune ressource associée à cette version Sélectionner une option de ressource Plusieurs ressources disponibles Plusieurs fichiers installables sont disponibles pour cette version. Veuillez examiner la liste et sélectionner celui qui correspond à votre appareil. Informations Réessayer Détection automatique : %1$s Sélectionner la langue Incompatibilité de paquet : l\'APK est %1$s, mais l\'application installée est %2$s. Mise à jour bloquée. Incompatibilité de clé de signature : la mise à jour a été signée par un développeur différent. Mise à jour bloquée. Effet verre liquide Améliorez l\'interface avec une apparence vitreuse et fluide Masquer les dépôts consultés Masquer les dépôts que vous avez déjà consultés des flux de découverte Effacer l\'historique des consultations Réinitialiser tous les dépôts consultés pour qu\'ils réapparaissent dans les flux Historique des consultations effacé Consulté ================================================ FILE: core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml ================================================ GitHub Store इंस्टॉल किए गए ऐप्स वापस जाएँ अपडेट के लिए जाँच करें %1$s को लॉन्च नहीं किया जा सकता %1$s को खोलने में विफल रहा %1$s अपडेट करने में विफल रहा: %2$s अपडेट विफल सभी अपडेट विफल रहे: %1$s सभी ऐप्स सफलतापूर्वक अपडेट हो गए कोई अपडेट उपलब्ध नहीं अपने ऐप्स खोजें कोई ऐप नहीं मिला सभी अपडेट करें अपडेट खोलें रद्द करें जाँच हो रही है... सफलतापूर्वक अपडेट किया गया गलती: %1$s %2$d में से %1$d अपडेट हो रहा है वर्तमान में: %1$s अनुमति की प्रतीक्षा की जा रही है... साइन इन किया गया! अब आप ऐप का इस्तेमाल कर सकते हैं। रीडायरेक्ट किया जा रहा है… पुनः प्रयास करें गलती: %1$s यह कोड GitHub पर डालें: कोड कॉपी करें GitHub खोलें पूरा अनुभव अनलॉक\nकरें और अनुरोध ज़्यादा API रेट लिमिट पाने और रुकावटों से बचने के लिए साइन इन करें। GitHub से साइन इन करें रद्द किया गया अज्ञात त्रुटि भाषा: रिपॉजिटरीज़ खोजें रेपो, विवरण खोजें… भाषा के अनुसार फ़िल्टर करें %1$d परिणाम मिले क्रमबद्ध करें बंद सबसे अधिक स्टार सबसे अधिक फोर्क्स सबसे अच्छा मिलान अवरोही आरोही क्रमबद्ध करें सभी भाषाएँ Kotlin Java JavaScript TypeScript Python Swift Rust Go C# C++ C Dart Ruby PHP खोज विफल रही कोई रिपॉजिटरी नहीं मिली प्रोफ़ाइल उपस्थिति के बारे में नेटवर्क थीम रंग AMOLED ब्लैक थीम डार्क मोड के लिए गहरा काला बैकग्राउंड चुना हुआ रंग: %1$s संस्करण सहायता & समर्थन लॉग आउट सफलतापूर्वक लॉग आउट हो गए, रीडायरेक्ट किया जा रहा है... कैश सफलतापूर्वक साफ़ किया गया चेतावनी! क्या आप लॉग आउट करना चाहते हैं? डायनामिक ओशन पर्पल फॉरेस्ट स्लेट एम्बर रिपॉजिटरी खोलें ब्राउज़र में खोलें डाउनलोड रद्द करें इंस्टॉल विकल्प दिखाएँ विवरण लोड करने में त्रुटि पुन: प्रयास करें कोई विवरण नहीं दिया गया है। कोई रिलीज़ नोट्स नहीं। समस्या की रिपोर्ट करें इस ऐप के बारे में इंस्टॉल लॉग्स लेखक नया क्या है स्थापित उपलब्ध अपडेट उपलब्ध नहीं है नवीनतम स्थापित करें पुनः इंस्टॉल करें ऐप अपडेट करें डाउनलोड हो रहा है अपडेट हो रहा है सत्यापन हो रहा है स्थापित हो रहा है ओब्टेनियम में खोलें अपडेट को अपने आप मैनेज करें ऐपमैनेजर से जांच करें अनुमतियाँ, ट्रैकर्स जाँचें & सुरक्षा प्रोफ़ाइल फोर्क्स स्टार्स इश्यूज़ द्वारा %1$s • स्थापित: %1$s आर्किटेक्चर संगत %1$s पर अपडेट करें विवरण लोड करने में विफल इंस्टॉलर को डाउनलोड फ़ोल्डर में सेव कर दिया गया है। डाउनलोड शुरू हो गया डाउनलोड हुआ अपडेट शुरू हो गया स्थापित अद्यतन रद्द किया गया इंस्टॉलेशन शुरू हो गया गलती गलती: %1$s ऐपमैनेजर के लिए तैयारी कर रहा है ऐप मैनेजर में खोला गया डिवाइस नीति द्वारा इंस्टॉल अनुमति अवरुद्ध बाहरी इंस्टॉलर में खोला गया इंस्टॉल अनुमति उपलब्ध नहीं APK सफलतापूर्वक डाउनलोड हो गया लेकिन यह डिवाइस सीधे इंस्टॉल करने की अनुमति नहीं देता। क्या आप इसे बाहरी इंस्टॉलर से खोलना चाहेंगे? बाहरी इंस्टॉलर से खोलें APK इंस्टॉल करने के लिए तृतीय-पक्ष ऐप का उपयोग करें गलती: %1$s संपदा प्रकार .%1$s समर्थित नहीं डाउनलोड की गई फ़ाइल नहीं मिली रुझान रिपॉजिटरी ढूंढी जा रही हैं... और लोड हो रहा है... अब और कोई रिपॉजिटरी नहीं पुन: प्रयास करें रिपॉजिटरी लोड करने में विफल रहा विवरण देखें अभी-अभी अपडेट किया गया %1$d घंटे पहले अपडेट किया गया कल अपडेट किया गया %1$d दिन पहले अपडेट किया गया %1$s को अपडेट किया गया दर सीमा पार हो गई आपने सभी %1$d API रिक्वेस्ट का इस्तेमाल कर लिया है। आपने सभी %1$d फ्री API रिक्वेस्ट इस्तेमाल कर लिए हैं। %1$d मिनट में रीसेट हो जाएगा 💡 60 रिक्वेस्ट प्रति घंटे के बजाय 5,000 रिक्वेस्ट प्रति घंटे पाने के लिए साइन इन करें! साइन इन ठीक है बंद करें सिस्टम फ़ॉन्ट बेहतर पठनीयता के लिए अपने डिवाइस के फ़ॉन्ट से मिलान करें। हल्का डार्क सिस्टम रिपॉजिटरी को पसंदीदा में जोड़ा गया रिपॉजिटरी को पसंदीदा से हटा दिया गया पसंदीदा में जोड़ें पसंदीदा से निकालें पसंदीदा अभी जोड़ा गया %1$d घंटे पहले जोड़ा गया कल जोड़ा गया %1$d दिन पहले जोड़ा गया %1$s को जोड़ा गया तारांकित रिपॉजिटरी रिपॉजिटरी स्टार की गई रिपॉजिटरी स्टार नहीं की गई आप GitHub से किसी रिपॉजिटरी को स्टार कर सकते हैं। आप GitHub से किसी रिपॉजिटरी को अनस्टार कर सकते हैं। साइन इन आवश्यक अपने स्टार किए गए रिपॉजिटरी देखने के लिए GitHub से साइन इन करें कोई तारांकित रिपॉजिटरी नहीं उन्हें यहां देखने के लिए इंस्टॉलेबल रिलीज़ वाले GitHub पर रिपॉजिटरी को स्टार करें। अंतिम सिंक अभी-अभी %1$d मिनट पहले %1$d घंटे पहले %1$d दिन पहले हटाएं तारांकित रिपॉजिटरी को सिंक करने में विफल रहा डेवलपर प्रोफ़ाइल डेवलपर प्रोफ़ाइल खोलें रिपॉजिटरी लोड करने में विफल रहा प्रोफ़ाइल लोड करने में विफल रिपॉजिटरी फ़ॉलोअर्स फ़ॉलो कर रहे हैं रिपॉजिटरी खोजें… खोज साफ़ करें सभी रिलीज़ के साथ स्थापित पसंदीदा क्रम हाल ही में अपडेट किया गया नाम रिपॉजिटरी रिपॉजिटरीज़ %2$d में से %1$d रिपॉजिटरी दिखाए जा रहे हैं इंस्टॉल करने योग्य रिलीज़ वाली कोई रिपॉजिटरी नहीं कोई रिपॉजिटरी इंस्टॉल नहीं है कोई पसंदीदा रिपॉजिटरी नहीं %1$s पहले अपडेट किया गया रिलीज़ हो गया है %1$d साल पहले %1$d महीने पहले %1$d दिन पहले %1$d घंटे पहले %1$d मिनट पहले %1$dM %1$dk अभी-अभी जारी किया गया %1$d घंटे पहले जारी किया गया कल जारी किया गया %1$d दिन पहले जारी किया गया %1$s को जारी किया गया होम खोज ऐप्स प्रोफ़ाइल फोर्क स्थिर प्री-रिलीज़ सभी संस्करण चुनें प्री-रिलीज़ कोई संस्करण चयनित नहीं संस्करण हॉट रिलीज़ सबसे लोकप्रिय इंस्टॉल लंबित अनइंस्टॉल खोलें डाउनग्रेड के लिए अनइंस्टॉल आवश्यक संस्करण %1$s इंस्टॉल करने के लिए पहले वर्तमान संस्करण (%2$s) को अनइंस्टॉल करना होगा। ऐप डेटा खो जाएगा। पहले अनइंस्टॉल करें %1$s इंस्टॉल करें %1$s खोलने में विफल %1$s अनइंस्टॉल करने में विफल नवीनतम अंतिम जाँच: %1$s कभी जाँच नहीं की अभी %1$d मिनट पहले %1$d घंटे पहले अपडेट की जाँच हो रही है… प्रॉक्सी प्रकार कोई नहीं सिस्टम HTTP SOCKS होस्ट पोर्ट उपयोगकर्ता नाम (वैकल्पिक) पासवर्ड (वैकल्पिक) प्रॉक्सी सहेजें प्रॉक्सी सेटिंग्स सहेजी गईं आपके डिवाइस की प्रॉक्सी सेटिंग का उपयोग करता है पोर्ट 1–65535 के बीच होना चाहिए सीधा कनेक्शन, कोई प्रॉक्सी नहीं प्रॉक्सी सेटिंग्स सहेजने में विफल प्रॉक्सी होस्ट आवश्यक है अमान्य प्रॉक्सी पोर्ट पासवर्ड दिखाएँ पासवर्ड छुपाएँ इस ऐप को ट्रैक करें ऐप ट्रैकिंग सूची में जोड़ा गया ऐप ट्रैक करने में विफल: %1$s ऐप पहले से ट्रैक हो रहा है GitHub में साइन इन करें पूरा अनुभव अनलॉक करें। अपने ऐप्स मैनेज करें, प्राथमिकताएँ सिंक करें और तेज़ी से ब्राउज़ करें। रिपॉजिटरी लॉगिन GitHub पर आपकी स्टार की गई रिपॉजिटरी स्थानीय रूप से सहेजी गई आपकी पसंदीदा रिपॉजिटरी सत्र समाप्त आपका GitHub सत्र समाप्त हो गया है या टोकन रद्द कर दिया गया है। प्रमाणित सुविधाओं का उपयोग जारी रखने के लिए कृपया फिर से साइन इन करें। आप सीमित API अनुरोधों के साथ अतिथि के रूप में ब्राउज़ कर सकते हैं। फिर से साइन इन करें अतिथि के रूप में जारी रखें यह आपका स्थानीय सत्र और कैश डेटा साफ़ कर देगा। पूर्ण रूप से पहुँच रद्द करने के लिए, GitHub Settings > Applications पर जाएँ। कोड %1$s में समाप्त होगा डिवाइस कोड की अवधि समाप्त हो गई है। नया कोड प्राप्त करने के लिए कृपया फिर से साइन इन करें। कृपया अपना इंटरनेट कनेक्शन जाँचें और पुनः प्रयास करें। आपने प्राधिकरण अनुरोध अस्वीकार कर दिया। यदि यह अनजाने में हुआ तो पुनः प्रयास करें। और पढ़ें कम दिखाएं रिपॉजिटरी साझा करें लिंक साझा करने में विफल लिंक क्लिपबोर्ड में कॉपी किया गया अनुवाद करें अनुवाद हो रहा है… मूल दिखाएं %1$s में अनुवादित अनुवाद करें… भाषा खोजें भाषा बदलें अनुवाद विफल। कृपया पुनः प्रयास करें। GitHub लिंक खोलें क्लिपबोर्ड में GitHub लिंक मिला क्लिपबोर्ड लिंक स्वतः पहचानें खोज खोलते समय क्लिपबोर्ड से GitHub लिंक स्वचालित रूप से पहचानें पहचाने गए लिंक ऐप में खोलें क्लिपबोर्ड में कोई GitHub लिंक नहीं मिला संग्रहण कैश साफ़ करें वर्तमान आकार: साफ़ करें GitHub Store का समर्थन करें प्रोजेक्ट को समर्थन दें प्यार से बनाया,\nकॉफी से चलाया GitHub Store ने 130,000+ डाउनलोड और 7,700+ GitHub स्टार प्राप्त किए हैं — 100% मुफ्त, बिना विज्ञापन और बिना ट्रैकिंग। मैं इस प्रोजेक्ट को पूरी तरह अकेले बनाता और संभालता हूँ जबकि मैं हाई स्कूल पूरा कर रहा हूँ। GitHub Store के लिए वोट करें! GitHub Store KotlinConf 2026 Golden Kodee Awards के लिए नामांकित है। 1. रजिस्टर करें 2. वोट करें वोटिंग 22 मार्च को बंद होगी 1. प्लेटफॉर्म पर रजिस्टर करें (Google से जारी रखें) 2. नीचे वोट बटन दबाएँ 3. Usmon Narzullayev खोजें और वोट करें GitHub Sponsors GitHub के माध्यम से एक बार या नियमित समर्थन Buy Me a Coffee त्वरित एक-बार समर्थन मदद करने के अन्य तरीके रिपॉजिटरी को स्टार दें दूसरों को GitHub Store खोजने में मदद करता है बग रिपोर्ट करें ऐप को बेहतर बनाता है दोस्तों के साथ साझा करें अन्य डेवलपर्स तक बात फैलाएँ हर तरह का समर्थन — चाहे आर्थिक हो या नहीं — इस प्रोजेक्ट को जीवित रखता है। धन्यवाद! इंस्टॉलेशन डिफ़ॉल्ट मानक सिस्टम इंस्टॉल डायलॉग Shizuku बिना पुष्टि के साइलेंट इंस्टॉल Shizuku इंस्टॉल नहीं है Shizuku चल नहीं रहा है अनुमति आवश्यक तैयार अनुमति दें साइलेंट इंस्टॉल सक्रिय करने के लिए Shizuku इंस्टॉल करें साइलेंट इंस्टॉल सक्रिय करने के लिए Shizuku शुरू करें Shizuku इंस्टॉल विफल, मानक इंस्टॉलर का उपयोग किया जा रहा है ऐप्स ऑटो-अपडेट करें Shizuku के माध्यम से पृष्ठभूमि में स्वचालित रूप से अपडेट डाउनलोड और इंस्टॉल करें अपडेट अपडेट जाँच अंतराल पृष्ठभूमि में ऐप अपडेट कितनी बार जाँचें 3घ 6घ 12घ 24घ लिंक से जोड़ें ऐप को रिपॉजिटरी से लिंक करें GitHub रिपॉजिटरी से लिंक करने के लिए एक इंस्टॉल किया हुआ ऐप चुनें ऐप्स खोजें… GitHub रिपॉजिटरी URL github.com/owner/repo सत्यापन हो रहा है… लिंक करें और ट्रैक करें नवीनतम रिलीज़ की जाँच हो रही है… सत्यापन के लिए APK डाउनलोड हो रहा है… साइनिंग कुंजी सत्यापित हो रही है… पैकेज नाम मेल नहीं खाता: APK %1$s है, लेकिन चयनित ऐप %2$s है साइनिंग कुंजी मेल नहीं खाती: इस रिपॉजिटरी का APK किसी अन्य डेवलपर द्वारा हस्ताक्षरित है इंस्टॉलर चुनें अपने इंस्टॉल किए गए ऐप से मिलान करने के लिए APK चुनें डाउनलोड विफल निर्यात आयात ऐप्स आयात करें अपने ट्रैक किए गए ऐप्स को पुनर्स्थापित करने के लिए निर्यात किया गया JSON पेस्ट करें निर्यात किया गया JSON यहाँ पेस्ट करें… प्री-रिलीज़ शामिल करें अपडेट की जाँच करते समय प्री-रिलीज़ संस्करणों को ट्रैक करें। अक्षम होने पर, केवल स्थिर रिलीज़ पर विचार किया जाता है। ऐप अनइंस्टॉल करें? क्या आप वाकई %1$s को अनइंस्टॉल करना चाहते हैं? यह क्रिया पूर्ववत नहीं की जा सकती और ऐप डेटा खो सकता है। अमान्य GitHub URL। प्रारूप का उपयोग करें: github.com/owner/repo रिपॉजिटरी नहीं मिली: %1$s/%2$s GitHub API दर सीमा पार हो गई। बाद में पुनः प्रयास करें। लिंक करने में विफल: %1$s इंस्टॉल किए गए ऐप्स लोड करने में विफल %1$s को %2$s/%3$s से लिंक किया गया निर्यात विफल: %1$s आयात विफल: %1$s %1$d ऐप्स आयात किए गए , %1$d छोड़े गए , %1$d विफल साइनिंग कुंजी बदल गई इस ऐप का साइनिंग प्रमाणपत्र पहली बार इंस्टॉल होने के बाद बदल गया है।\n\nइसका मतलब हो सकता है कि डेवलपर ने अपनी साइनिंग कुंजी बदल दी, या बाइनरी के साथ छेड़छाड़ की गई हो।\n\nअपेक्षित: %1$s\nप्राप्त: %2$s फिर भी इंस्टॉल करें सत्यापित बिल्ड जाँच हो रही है\u2026 संसाधन कोई संसाधन नहीं इस रिलीज़ से संबंधित कोई संसाधन नहीं संसाधन विकल्प चुनें कई संसाधन उपलब्ध इस रिलीज़ के लिए कई इंस्टॉल करने योग्य फ़ाइलें उपलब्ध हैं। कृपया सूची की समीक्षा करें और अपने डिवाइस के लिए उपयुक्त फ़ाइल चुनें। जानकारी पुनः प्रयास स्वतः पहचाना गया: %1$s भाषा चुनें पैकेज मेल नहीं खाता: APK %1$s है, लेकिन इंस्टॉल किया गया ऐप %2$s है। अपडेट ब्लॉक किया गया। साइनिंग कुंजी मेल नहीं खाती: अपडेट किसी अन्य डेवलपर द्वारा साइन किया गया था। अपडेट ब्लॉक किया गया। लिक्विड ग्लास इफ़ेक्ट एक चिकने काँच जैसे रूप से इंटरफ़ेस को बेहतर बनाएं देखे गए रिपॉजिटरी छुपाएँ पहले से देखे गए रिपॉजिटरी को डिस्कवरी फ़ीड से छुपाएँ देखने का इतिहास साफ़ करें सभी देखे गए रिपॉजिटरी रीसेट करें ताकि वे फ़ीड में फिर से दिखें देखने का इतिहास साफ़ किया गया देखा गया ================================================ FILE: core/presentation/src/commonMain/composeResources/values-it/strings-it.xml ================================================ GitHub Store App Installate Indietro Controlla Aggiornamenti Impossibile lanciare %1$s Impossibile aprire %1$s Impossibile aggiornare %1$s: %2$s Aggiornamento fallito Aggiornamento generale fallito: %1$s Tutte le app sono state aggiornate con successo Nessun aggiornamento disponibile Cerca le tue app Nessuna app trovata Aggiorna tutto Aggiorna Apri Cancella Controllo… Aggiornata con successo Errore: %1$s Aggiornando %1$d di %2$d Attualmente: %1$s In attesa di autorizzazione… Autenticazione effettuata! Ora puoi usare l'applicazione. Reindirizzamento… Riprova Errore: %1$s Inserisci questo codice su GitHub: Copia il codice Apri GitHub Sblocca l'esperienza\ncompleta Più richieste Effettua l'accesso per ottenere limiti API più alti e evitare interruzioni. Accedi con GitHub Annullato Errore sconosciuto Lingua: Scopri Repositories Cerca repo, descrizione… Filtra per lingua %1$d risultati trovati Ordina per Chiudi Più Stelle Più Forks Miglior corrispondenza Decrescente Crescente Ordina Tutte le lingue Kotlin Java JavaScript TypeScript Python Swift Rust Go C# C++ C Dart Ruby PHP Ricerca Fallita Nessuna repository trovata Profilo ASPETTO INFORMAZIONI RETE Colore del Tema Tema Amoled Nero Nero puro per il tema scuro Colore selezionato: %1$s Versione Aiuto & Supporto Esci Uscito con successo, reindirizzamento… Cache cancellata con successo Attenzione! Sei sicuro di voler uscire? Dinamico Oceano Viola Foresta Ardesia Ambra Apri repository Apri nel browser Annulla download Mostra opzioni di installazione Errore nel caricamento dei dettagli Riprova Nessuna descrizione fornita. Nessuna nota di aggiornamento. Segnala problema Informazioni su quest'app Installa logs Autore Cosa c'è di nuovo Installate Aggiornamento disponibile Non disponibile Installa l'ultima versione Reinstalla Aggiorna app Scaricamento Aggiornamento Verifica Installazione Apri in Obtainium Gestisci aggiornamenti automaticamente Ispeziona con AppManager Controlla permessi, trackers & sicurezza Profilo Forks Stelle Problemi per %1$s • Installate: %1$s Architettura compatibile Aggiorna a %1$s Impossibile caricare i dettagli L'installer è stato salvato nella cartella di download Download iniziato Scaricato Aggiornamento iniziato Installato Aggiornato Cancellato Installazione iniziata Errore Errore: %1$s Preparazione dell'AppManager Aperto nell'AppManager Permesso di installazione bloccato dalla policy del dispositivo Aperto nell\'installatore esterno Permesso di installazione non disponibile L\'APK è stato scaricato con successo ma questo dispositivo non consente l\'installazione diretta. Vuoi aprirlo con un installatore esterno? Apri con installatore esterno Usa un\'app di terze parti per installare l\'APK Errore: %1$s Tipo di archivio .%1$s non supportato File scaricato non trovato In tendenza Rilascio a caldo I più popolari Ricerca di repositories... Caricamento in corso... Non ci sono più repositories Riprova Impossibile caricare repositories Vedi Dettagli appena aggiornata aggiornata %1$d ora/e fa aggiornata ieri aggiornata %1$d giorno/i fa aggiornata il %1$s Superato limite di richieste Hai usato tutte le %1$d richieste API. Hai usato tutte le %1$d richieste API gratuite. Si resetta tra %1$d minuti 💡 Effettua l'accesso per ottenere 5,000 richieste all'ora invece di 60! Accesso OK Chiudi Font del sistema Usa il font di sistema per una migliore leggibilità Repository aggiunto ai preferiti Repository rimosso dai preferiti Aggiungi ai preferiti Rimuovi dai preferiti Preferiti aggiunto poco fa aggiunto %1$d ora(e) fa aggiunto ieri aggiunto %1$d giorno(i) fa aggiunto il %1$s Repository preferiti Il repository è nei preferiti Il repository non è nei preferiti Puoi aggiungere il repository ai preferiti su GitHub Puoi rimuovere il repository dai preferiti su GitHub Accesso richiesto Nessun repository preferito Accedi con GitHub per vedere i repository preferiti Aggiungi repository con release installabili su GitHub per vederli qui Ultima sincronizzazione Poco fa %1$d min fa %1$d h fa %1$d g fa Chiudi Impossibile sincronizzare i repository preferiti Profilo dello sviluppatore Apri il profilo dello sviluppatore Impossibile caricare i repository Impossibile caricare il profilo Repository Follower Seguiti Cerca repository… Cancella ricerca Tutti Con release Installati Preferiti Ordina Aggiornati di recente Nome repository repository Visualizzazione di %1$d su %2$d repository Nessun repository con release installabili Nessun repository installato Nessun repository preferito Aggiornato %1$s fa Ha una release %1$d a fa %1$d m fa %1$d g fa %1$d h fa %1$d min fa %1$dM %1$dk Pubblicato ora Pubblicato %1$d ora/e fa Pubblicato ieri Pubblicato %1$d giorno/i fa Pubblicato il %1$s Home Cerca App Profilo Fork Stabile Pre-release Tutte Seleziona versione Pre-release Nessuna versione selezionata Versioni Installazione in sospeso Disinstalla Apri Il downgrade richiede la disinstallazione L\'installazione della versione %1$s richiede la disinstallazione della versione corrente (%2$s). I dati dell\'app verranno persi. Disinstalla prima Installa %1$s Impossibile aprire %1$s Impossibile disinstallare %1$s Ultima Chiaro Scuro Sistema Ultimo controllo: %1$s Mai controllato proprio ora %1$d min fa %1$d h fa Controllo aggiornamenti… Tipo di proxy Nessuno Sistema HTTP SOCKS Host Porta Nome utente (facoltativo) Password (facoltativo) Salva Proxy Impostazioni proxy salvate Usa le impostazioni proxy del dispositivo La porta deve essere 1–65535 Connessione diretta, nessun proxy Impossibile salvare le impostazioni del proxy L'host del proxy è obbligatorio Porta proxy non valida Mostra password Nascondi password Traccia questa app App aggiunta alla lista di monitoraggio Impossibile tracciare l\'app: %1$s L\'app è già monitorata Accedi a GitHub Sblocca l\'esperienza completa. Gestisci le tue app, sincronizza le preferenze e naviga più velocemente. Repo Accedi I tuoi repository preferiti su GitHub I tuoi repository preferiti salvati localmente Sessione scaduta La tua sessione GitHub è scaduta o il token è stato revocato. Accedi di nuovo per continuare a utilizzare le funzionalità autenticate. Puoi continuare a navigare come ospite con richieste API limitate. Accedi di nuovo Continua come ospite Questo cancellerà la sessione locale e i dati nella cache. Per revocare completamente l\'accesso, visita GitHub Settings > Applications. Il codice scade tra %1$s Il codice del dispositivo è scaduto. Riprova ad accedere per ottenere un nuovo codice. Controlla la tua connessione internet e riprova. Hai rifiutato la richiesta di autorizzazione. Riprova se è stato involontario. Leggi di più Mostra meno Condividi repository Impossibile condividere il link Link copiato negli appunti Traduci Traduzione… Mostra originale Tradotto in %1$s Traduci in… Cerca lingua Cambia lingua Traduzione fallita. Riprova. Apri link GitHub Link GitHub rilevato negli appunti Rileva link dagli appunti Rileva automaticamente i link GitHub dagli appunti all\'apertura della ricerca Link rilevati Apri nell\'app Nessun link GitHub trovato negli appunti Archiviazione Pulisci cache Dimensione attuale: Pulisci Supporta GitHub Store Supporta il progetto Costruito con amore,\nmantenuto con il caffè GitHub Store ha raggiunto oltre 130.000 download e 7.700 stelle su GitHub — 100% gratuito, senza pubblicità né tracciamento. Ho costruito e mantengo questo progetto completamente da solo mentre finisco il liceo. Il tuo supporto — anche piccolo — aiuta a mantenere l'app senza bug e a finanziare l'infrastruttura. Vota GitHub Store! GitHub Store è nominato ai Golden Kodee Awards al KotlinConf 2026. 1. Registrati 2. Vota Le votazioni chiudono il 22 marzo 1. Registrati sulla piattaforma (Continua con Google) 2. Tocca Vota qui sotto 3. Trova Usmon Narzullayev e premi Vota GitHub Sponsors Supporto ricorrente o singolo via GitHub Buy Me a Coffee Supporto rapido una tantum ALTRI MODI PER AIUTARE Metti una stella al repository Aiuta altri a scoprire GitHub Store Segnala bug Rende l'app migliore per tutti Condividi con amici Diffondi la parola tra gli sviluppatori Ogni supporto — finanziario o meno — mantiene vivo questo progetto. Grazie! Installazione Predefinito Finestra di installazione standard del sistema Shizuku Installazione silenziosa senza conferme Shizuku non è installato Shizuku non è in esecuzione Autorizzazione necessaria Pronto Concedi autorizzazione Installa Shizuku per abilitare l\'installazione silenziosa Avvia Shizuku per abilitare l\'installazione silenziosa Installazione tramite Shizuku fallita, utilizzo dell\'installatore standard Aggiornamento automatico Scarica e installa automaticamente gli aggiornamenti in background tramite Shizuku Aggiornamenti Intervallo di controllo Ogni quanto verificare gli aggiornamenti in background 3h 6h 12h 24h Aggiungi tramite link Collega app al repository Scegli un\'app installata da collegare a un repository GitHub Cerca app… URL del repository GitHub github.com/owner/repo Validazione… Collega e monitora Controllo dell\'ultima versione… Download APK per la verifica… Verifica della chiave di firma… Nome pacchetto diverso: l\'APK è %1$s, ma l\'app selezionata è %2$s Chiave di firma diversa: l\'APK di questo repository è stato firmato da uno sviluppatore diverso Seleziona installatore Scegli l\'APK da verificare con la tua app installata Download fallito Esporta Importa Importa app Incolla il JSON esportato per ripristinare le app monitorate Incolla il JSON esportato qui… Includi pre-release Monitora le versioni pre-release durante il controllo aggiornamenti. Se disabilitato, vengono considerate solo le versioni stabili. Disinstallare l\'app? Sei sicuro di voler disinstallare %1$s? Questa azione non può essere annullata e i dati dell\'app potrebbero andare persi. URL GitHub non valido. Usa il formato: github.com/owner/repo Repository non trovato: %1$s/%2$s Limite API GitHub superato. Riprova più tardi. Collegamento fallito: %1$s Impossibile caricare le app installate %1$s collegata a %2$s/%3$s Esportazione fallita: %1$s Importazione fallita: %1$s %1$d app importate , %1$d saltate , %1$d fallite Chiave di firma cambiata Il certificato di firma di questa app è cambiato dalla prima installazione.\n\nQuesto potrebbe significare che lo sviluppatore ha cambiato la chiave di firma, o il binario potrebbe essere stato alterato.\n\nPrevisto: %1$s\nRicevuto: %2$s Installa comunque Build verificata Controllo\u2026 Risorse Nessuna risorsa Nessuna risorsa associata a questa versione Seleziona opzione risorsa Risorse multiple disponibili Ci sono più file installabili disponibili per questa versione. Controlla la lista e seleziona quello adatto al tuo dispositivo. Informazioni Riprova Rilevato automaticamente: %1$s Seleziona lingua Pacchetto non corrispondente: l\'APK è %1$s, ma l\'app installata è %2$s. Aggiornamento bloccato. Chiave di firma non corrispondente: l\'aggiornamento è stato firmato da uno sviluppatore diverso. Aggiornamento bloccato. Effetto vetro liquido Migliora l\'interfaccia con un aspetto liscio simile al vetro Nascondi repository visualizzati Nascondi i repository già visualizzati dai feed di scoperta Cancella cronologia visualizzazioni Reimposta tutti i repository visualizzati in modo che ricompaiano nei feed Cronologia visualizzazioni cancellata Visualizzato ================================================ FILE: core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml ================================================ GitHub Store インストール済みアプリ 戻る 更新を確認 %1$s を起動できません %1$s を開けませんでした %1$s の更新に失敗しました:%2$s 更新に失敗しました すべての更新に失敗しました:%1$s すべてのアプリが更新されました 更新はありません アプリを検索 アプリが見つかりません すべて更新 更新 開く キャンセル 確認中… 更新完了 エラー:%1$s %2$d 件中 %1$d 件を更新中 現在:%1$s 認証を待機中… ログイン完了! アプリを使用できます。リダイレクト中… 再試行 エラー:%1$s GitHubでこのコードを入力してください: コードをコピー GitHubを開く すべての機能を\n解放 リクエスト数を増加 ログインして API の制限を引き上げ、中断を防ぎましょう。 GitHubでサインイン キャンセルされました 不明なエラー 言語: リポジトリを探索 リポジトリや説明を検索… 言語でフィルター %1$d 件の結果 再試行 並び替え 閉じる スター数順 フォーク数順 最適な一致 降順 昇順 並び替え すべての言語 Kotlin Java JavaScript TypeScript Python Swift Rust Go C# C++ C Dart Ruby PHP 検索に失敗しました リポジトリが見つかりませんでした プロフィール 外観 情報 ネットワーク テーマカラー AMOLED ブラックテーマ ダークモード用の純黒背景 選択された色:%1$s バージョン ヘルプとサポート ログアウト ログアウトしました。リダイレクト中… キャッシュを正常にクリアしました 警告! ログアウトしてもよろしいですか? ダイナミック オーシャン パープル フォレスト スレート アンバー 詳細の読み込みに失敗しました このアプリについて インストールログ 作者 新機能 問題を報告 インストール済み 更新あり 最新版をインストール 再インストール ダウンロード中 更新中 検証中 インストール中 プロフィール フォーク スター 課題 %1$s 作 • インストール済み: %1$s アーキテクチャ互換 %1$s に更新 詳細を読み込めませんでした インストーラーはダウンロードに保存されました ダウンロード開始 ダウンロード完了 更新開始 インストール済み 更新済み キャンセルされました インストールを開始しました エラー エラー: %1$s AppManager 用に準備中 AppManager で開きました デバイスポリシーによりインストール権限がブロックされました 外部インストーラーで開きました インストール権限が利用できません APKは正常にダウンロードされましたが、このデバイスでは直接インストールが許可されていません。外部インストーラーで開きますか? 外部インストーラーで開く サードパーティアプリを使用してAPKをインストール エラー: %1$s ファイル形式 .%1$s は未対応です ダウンロードしたファイルが見つかりません トレンド ホットリリース 最も人気のある リポジトリを検索中… 読み込み中… これ以上ありません 再試行 リポジトリの読み込みに失敗しました 詳細を見る たった今更新 %1$d時間前に更新 昨日更新 %1$d日前に更新 %1$s に更新 レート制限を超えました %1$d 件のAPIリクエストをすべて使用しました。 %1$d 件の無料APIリクエストをすべて使用しました。 %1$d分後にリセット 💡 サインインすると、1時間あたり5,000件のリクエストが可能です。 サインイン OK 閉じる システムフォント デバイスのフォントを使用して読みやすさを向上 ライト ダーク システム リポジトリをお気に入りに追加しました リポジトリをお気に入りから削除しました お気に入りに追加 お気に入りから削除 お気に入り たった今追加されました %1$d時間前に追加 昨日追加 %1$d日前に追加 %1$s に追加 スター付きリポジトリ リポジトリはスター済みです リポジトリはスターされていません GitHub でリポジトリにスターを付けられます GitHub でスターを解除できます ログインが必要です スター付きリポジトリはありません GitHubでログインしてスター付きリポジトリを表示します インストール可能なリリースのあるリポジトリにスターを付けてください 最終同期 たった今 %1$d分前 %1$d時間前 %1$d日前 閉じる スター付きリポジトリの同期に失敗しました 開発者プロフィール 開発者プロフィールを開く リポジトリの読み込みに失敗しました プロフィールの読み込みに失敗しました リポジトリ フォロワー フォロー中 リポジトリを検索… 検索をクリア すべて リリースあり インストール済み お気に入り 並べ替え 最近更新 名前 リポジトリ リポジトリ %2$d件中%1$d件のリポジトリを表示 インストール可能なリリースを持つリポジトリがありません インストール済みのリポジトリがありません お気に入りのリポジトリがありません %1$sに更新 リリースあり %1$d 年前 %1$d ヶ月前 %1$d 日前 %1$d 時間前 %1$d 分前 %1$dM %1$dk たった今リリース %1$d時間前にリリース 昨日リリース %1$d日前にリリース %1$sにリリース ホーム 検索 アプリ プロフィール フォーク 安定版 プレリリース すべて バージョンを選択 プレリリース バージョン未選択 バージョン リポジトリを開く ブラウザで開く ダウンロードをキャンセル インストールオプションを表示 説明はありません。 リリースノートはありません。 利用不可 アプリを更新 インストール待ち Obtainiumで開く 自動的にアップデートを管理 AppManagerで検査 権限、トラッカー、セキュリティを確認 アンインストール 開く ダウングレードにはアンインストールが必要 バージョン%1$sのインストールには、現在のバージョン(%2$s)のアンインストールが必要です。アプリデータは失われます。 先にアンインストール %1$sをインストール %1$sを開けませんでした %1$sのアンインストールに失敗しました 最新 最終確認: %1$s 未確認 たった今 %1$d分前 %1$d時間前 アップデートを確認中… プロキシの種類 なし システム HTTP SOCKS ホスト ポート ユーザー名(任意) パスワード(任意) プロキシを保存 プロキシ設定を保存しました デバイスのプロキシ設定を使用します ポートは1〜65535の範囲で指定してください 直接接続、プロキシなし プロキシ設定の保存に失敗しました プロキシホストは必須です 無効なプロキシポート パスワードを表示 パスワードを非表示 このアプリを追跡 アプリを追跡リストに追加しました アプリの追跡に失敗しました: %1$s このアプリは既に追跡中です GitHubにサインイン すべての機能を解放しましょう。アプリを管理し、設定を同期し、より速く閲覧できます。 リポジトリ ログイン GitHubのスター付きリポジトリ ローカルに保存されたお気に入りリポジトリ セッション期限切れ GitHubセッションの有効期限が切れたか、トークンが取り消されました。認証機能を引き続き使用するには、再度サインインしてください。 制限されたAPIリクエストでゲストとして閲覧を続けることができます。 再度サインイン ゲストとして続行 ローカルセッションとキャッシュデータが消去されます。アクセスを完全に取り消すには、GitHub Settings > Applicationsにアクセスしてください。 コードの有効期限: %1$s デバイスコードの有効期限が切れました。 新しいコードを取得するために再度サインインしてください。 インターネット接続を確認して再試行してください。 認証リクエストを拒否しました。意図しない場合は再試行してください。 もっと読む 折りたたむ リポジトリを共有 リンクの共有に失敗しました リンクをクリップボードにコピーしました 翻訳 翻訳中… 原文を表示 %1$sに翻訳済み 翻訳先… 言語を検索 言語を変更 翻訳に失敗しました。もう一度お試しください。 GitHubリンクを開く クリップボードにGitHubリンクを検出 クリップボードリンクの自動検出 検索画面を開く際にクリップボードからGitHubリンクを自動検出 検出されたリンク アプリで開く クリップボードにGitHubリンクが見つかりません ストレージ キャッシュをクリア 現在のサイズ: クリア GitHub Store を支援 プロジェクトを支援 愛を込めて作り、\nコーヒーで維持 GitHub Store は 13万以上のダウンロードと 7700 以上の GitHub スターを達成しました。完全無料、広告なし、追跡なし。 私は高校を卒業しながら、このプロジェクトを一人で開発・維持しています。小さな支援でも、バグ修正やインフラ費用、機能開発に役立ちます。 GitHub Store に投票! KotlinConf 2026 の Golden Kodee Awards にノミネートされています。投票は2分で完了します。 1. 登録 2. 投票 投票締切:3月22日 1. 賞のプラットフォームに登録(Googleで続行) 2. 下の「投票」をタップ 3. Usmon Narzullayev を見つけて投票 GitHub Sponsors GitHub 経由の定期または一回支援 Buy Me a Coffee 簡単な一回支援 他の支援方法 リポジトリにスター 他の人が GitHub Store を見つけやすくなります バグを報告 アプリをより良くします 友達と共有 他の開発者に広める 金銭的であれそうでなくても、すべての支援がこのプロジェクトを支えています。ありがとうございます! インストール デフォルト 標準のシステムインストールダイアログ Shizuku 確認なしのサイレントインストール Shizukuがインストールされていません Shizukuが実行されていません 権限が必要です 準備完了 権限を付与 サイレントインストールを有効にするにはShizukuをインストールしてください サイレントインストールを有効にするにはShizukuを起動してください Shizukuインストールに失敗、標準インストーラーを使用します アプリを自動更新 Shizukuを使用してバックグラウンドで自動的にアップデートをダウンロードしてインストール アップデート アップデート確認間隔 バックグラウンドでアプリのアップデートを確認する頻度 3時間 6時間 12時間 24時間 リンクで追加 アプリをリポジトリにリンク GitHubリポジトリにリンクするインストール済みアプリを選択 アプリを検索… GitHubリポジトリURL github.com/owner/repo 検証中… リンクして追跡 最新リリースを確認中… 検証用APKをダウンロード中… 署名キーを検証中… パッケージ名が一致しません:APKは%1$sですが、選択されたアプリは%2$sです 署名キーが一致しません:このリポジトリのAPKは別の開発者によって署名されています インストーラーを選択 インストール済みアプリと照合するAPKを選択 ダウンロード失敗 エクスポート インポート アプリをインポート エクスポートしたJSONを貼り付けて追跡中のアプリを復元 エクスポートしたJSONをここに貼り付け… プレリリースを含める アップデート確認時にプレリリース版を追跡します。無効の場合、安定版リリースのみが対象となります。 アプリをアンインストールしますか? %1$sをアンインストールしてよろしいですか?この操作は元に戻せず、アプリのデータが失われる可能性があります。 無効なGitHub URL。形式を使用してください:github.com/owner/repo リポジトリが見つかりません:%1$s/%2$s GitHub APIのレート制限を超えました。後でもう一度お試しください。 リンクに失敗:%1$s インストール済みアプリの読み込みに失敗 %1$sを%2$s/%3$sにリンクしました エクスポート失敗:%1$s インポート失敗:%1$s %1$d個のアプリをインポート 、%1$d個スキップ 、%1$d個失敗 署名キーが変更されました このアプリの署名証明書が初回インストール以降に変更されました。\n\nこれは開発者が署名キーを変更したか、バイナリが改ざんされた可能性があります。\n\n期待値:%1$s\n受信値:%2$s それでもインストール 検証済みビルド 確認中\u2026 アセット アセットなし このリリースに関連するアセットはありません アセットオプションを選択 複数のアセットが利用可能 このリリースには複数のインストール可能なファイルがあります。リストを確認し、お使いのデバイスに合ったものを選択してください。 情報 再試行 自動検出:%1$s 言語を選択 パッケージの不一致: APKは%1$sですが、インストール済みアプリは%2$sです。更新がブロックされました。 署名キーの不一致: 更新は別の開発者によって署名されています。更新がブロックされました。 リキッドグラスエフェクト 滑らかなガラスのような外観でインターフェースを向上させます 閲覧済みリポジトリを非表示 すでに閲覧したリポジトリをディスカバリーフィードから非表示にします 閲覧履歴をクリア すべての閲覧済みリポジトリをリセットしてフィードに再表示します 閲覧履歴をクリアしました 閲覧済み ================================================ FILE: core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml ================================================ GitHub Store 설치된 앱 뒤로 이동 업데이트 확인 %1$s을(를) 실행할 수 없습니다 %1$s을(를) 열지 못했습니다 %1$s 업데이트 실패: %2$s 업데이트 실패 모두 업데이트 실패: %1$s 모든 앱이 성공적으로 업데이트되었습니다 사용 가능한 업데이트가 없습니다 앱 검색 앱을 찾을 수 없습니다 모두 업데이트 업데이트 열기 취소 확인 중… 성공적으로 업데이트됨 오류: %1$s %2$d개 중 %1$d개 업데이트 중 현재 업데이트 중: %1$s 인증 대기 중… 로그인 완료! 이제 앱을 사용할 수 있습니다. 이동 중… 다시 시도 오류: %1$s GitHub에 다음 코드를 입력하세요: 코드 복사 GitHub 열기 전체\n기능 잠금 해제 더 많은 요청 로그인하여 API 요청 한도를 늘리고 중단 없이 사용하세요. GitHub로 로그인 취소됨 알 수 없는 오류 언어: 저장소 탐색 저장소, 설명 검색… 언어로 필터링 %1$d개의 결과를 찾았습니다 정렬 기준 닫기 별 많은 순 포크 많은 순 최적의 결과 내림차순 오름차순 정렬 모든 언어 Kotlin Java JavaScript TypeScript Python Swift Rust Go C# C++ C Dart Ruby PHP 검색 실패 저장소를 찾을 수 없습니다 프로필 외관 정보 네트워크 테마 색상 AMOLED 블랙 테마 다크 모드용 순수 블랙 배경 선택된 색상: %1$s 버전 도움말 및 지원 로그아웃 성공적으로 로그아웃되었습니다. 이동 중... 캐시가 성공적으로 삭제되었습니다 경고! 정말 로그아웃하시겠습니까? 동적 오션 퍼플 포레스트 슬레이트 앰버 저장소 열기 브라우저에서 열기 다운로드 취소 설치 옵션 보기 상세 정보를 불러오는 중 오류 발생 다시 시도 설명이 없습니다. 릴리스 노트가 없습니다. 문제 신고 이 앱 정보 설치 로그 작성자 새로운 기능 설치됨 업데이트 가능 사용 불가 최신 버전 설치 재설치 앱 업데이트 다운로드 중 업데이트 중 검증 중 설치 중 Obtainium에서 열기 업데이트 자동 관리 AppManager로 검사 권한, 트래커 및 보안 확인 프로필 포크 이슈 %1$s 작성 • 설치됨: %1$s 아키텍처 호환 %1$s(으)로 업데이트 상세 정보를 불러오지 못했습니다 설치 파일이 다운로드 폴더에 저장되었습니다 다운로드 시작됨 다운로드 완료 업데이트 시작됨 설치됨 업데이트됨 취소됨 설치 시작됨 오류 오류: %1$s AppManager 준비 중 AppManager에서 열림 기기 정책에 의해 설치 권한이 차단됨 외부 설치 프로그램에서 열림 설치 권한을 사용할 수 없음 APK가 성공적으로 다운로드되었지만 이 기기에서는 직접 설치가 허용되지 않습니다. 외부 설치 프로그램으로 열까요? 외부 설치 프로그램으로 열기 타사 앱을 사용하여 APK 설치 오류: %1$s .%1$s 파일 형식은 지원되지 않습니다 다운로드된 파일을 찾을 수 없습니다 인기 핫 릴리스 가장 인기 있는 저장소를 찾는 중... 더 불러오는 중... 더 이상 저장소가 없습니다 다시 시도 저장소를 불러오지 못했습니다 상세 보기 방금 업데이트됨 %1$d시간 전 업데이트됨 어제 업데이트됨 %1$d일 전 업데이트됨 %1$s에 업데이트됨 요청 한도 초과 %1$d개의 API 요청을 모두 사용했습니다. %1$d개의 무료 API 요청을 모두 사용했습니다. %1$d분 후 초기화 💡 로그인하면 시간당 60회 대신 5,000회 요청을 사용할 수 있습니다! 로그인 확인 닫기 시스템 글꼴 가독성을 위해 기기 글꼴과 일치 라이트 다크 시스템 리포지토리를 즐겨찾기에 추가했습니다 리포지토리를 즐겨찾기에서 제거했습니다 즐겨찾기에 추가 즐겨찾기에서 제거 즐겨찾기 방금 추가됨 %1$d시간 전에 추가됨 어제 추가됨 %1$d일 전에 추가됨 %1$s에 추가됨 별표 표시된 저장소 저장소에 별표가 추가되었습니다 저장소에 별표가 없습니다 GitHub에서 저장소에 별표를 추가할 수 있습니다 GitHub에서 별표를 제거할 수 있습니다 로그인이 필요합니다 별표 표시된 저장소가 없습니다 GitHub로 로그인하여 별표 저장소를 확인하세요 설치 가능한 릴리스가 있는 저장소에 별표를 추가하세요 마지막 동기화 방금 %1$d분 전 %1$d시간 전 %1$d일 전 닫기 별표 저장소 동기화에 실패했습니다 개발자 프로필 개발자 프로필 열기 저장소를 불러오지 못했습니다 프로필을 불러오지 못했습니다 저장소 팔로워 팔로잉 저장소 검색… 검색 지우기 전체 릴리스 포함 설치됨 즐겨찾기 정렬 최근 업데이트 이름 저장소 저장소 총 %2$d개 중 %1$d개의 저장소 표시 설치 가능한 릴리스가 있는 저장소가 없습니다 설치된 저장소가 없습니다 즐겨찾기 저장소가 없습니다 %1$s 업데이트됨 릴리스 있음 %1$d 년 전 %1$d 개월 전 %1$d 일 전 %1$d 시간 전 %1$d 분 전 %1$dM %1$dk 방금 출시됨 %1$d시간 전에 출시됨 어제 출시됨 %1$d일 전에 출시됨 %1$s에 출시됨 검색 프로필 포크 안정 버전 사전 출시 전체 버전 선택 사전 출시 선택된 버전 없음 버전 설치 대기 중 제거 열기 다운그레이드를 위해 제거가 필요합니다 버전 %1$s을(를) 설치하려면 현재 버전(%2$s)을 먼저 제거해야 합니다. 앱 데이터가 삭제됩니다. 먼저 제거 %1$s 설치 %1$s 열기 실패 %1$s 제거 실패 최신 마지막 확인: %1$s 확인한 적 없음 방금 %1$d분 전 %1$d시간 전 업데이트 확인 중… 프록시 유형 없음 시스템 HTTP SOCKS 호스트 포트 사용자 이름 (선택 사항) 비밀번호 (선택 사항) 프록시 저장 프록시 설정이 저장되었습니다 기기의 프록시 설정을 사용합니다 포트는 1–65535 사이여야 합니다 직접 연결, 프록시 없음 프록시 설정을 저장하지 못했습니다 프록시 호스트가 필요합니다 잘못된 프록시 포트 비밀번호 표시 비밀번호 숨기기 이 앱 추적 앱이 추적 목록에 추가되었습니다 앱 추적 실패: %1$s 이미 추적 중인 앱입니다 GitHub에 로그인 전체 기능을 잠금 해제하세요. 앱을 관리하고, 설정을 동기화하고, 더 빠르게 탐색하세요. 저장소 로그인 GitHub에서 별표한 저장소 로컬에 저장된 즐겨찾기 저장소 세션 만료 GitHub 세션이 만료되었거나 토큰이 취소되었습니다. 인증된 기능을 계속 사용하려면 다시 로그인하세요. 제한된 API 요청으로 게스트로 계속 탐색할 수 있습니다. 다시 로그인 게스트로 계속 로컬 세션과 캐시 데이터가 삭제됩니다. 접근을 완전히 취소하려면 GitHub Settings > Applications를 방문하세요. 코드 만료까지 %1$s 디바이스 코드가 만료되었습니다. 새 코드를 받으려면 다시 로그인해 주세요. 인터넷 연결을 확인하고 다시 시도하세요. 인증 요청을 거부했습니다. 의도하지 않은 경우 다시 시도하세요. 더 보기 간략히 저장소 공유 링크 공유에 실패했습니다 링크가 클립보드에 복사되었습니다 번역 번역 중… 원문 보기 %1$s로 번역됨 번역 대상… 언어 검색 언어 변경 번역 실패. 다시 시도해주세요. GitHub 링크 열기 클립보드에서 GitHub 링크 감지됨 클립보드 링크 자동 감지 검색을 열 때 클립보드에서 GitHub 링크를 자동으로 감지 감지된 링크 앱에서 열기 클립보드에서 GitHub 링크를 찾을 수 없습니다 저장 공간 캐시 지우기 현재 크기: 지우기 GitHub Store 지원 프로젝트 지원하기 사랑으로 만들고,\n커피로 유지합니다 GitHub Store는 130,000+ 다운로드와 7,700+ GitHub 스타를 달성했습니다 — 100% 무료, 광고 없음, 추적 없음. 저는 고등학교를 마치면서 이 프로젝트를 혼자 개발하고 유지하고 있습니다. GitHub Store에 투표하세요! GitHub Store가 KotlinConf 2026 Golden Kodee Awards 후보에 올랐습니다. 1. 등록 2. 투표 투표 마감: 3월 22일 1. 플랫폼에 등록 (Google로 계속) 2. 아래에서 투표 버튼 누르기 3. Usmon Narzullayev를 찾아 투표 클릭 GitHub Sponsors GitHub를 통한 정기 또는 일회 지원 Buy Me a Coffee 빠른 일회 지원 다른 도움 방법 저장소에 스타 주기 다른 사람들이 GitHub Store를 발견하도록 도움 버그 신고 앱을 더 좋게 만듭니다 친구와 공유 다른 개발자에게 알려주세요 금전적이든 아니든 모든 지원이 이 프로젝트를 계속 유지하게 합니다. 감사합니다! 설치 기본 표준 시스템 설치 대화 상자 Shizuku 확인 없이 자동 설치 Shizuku가 설치되지 않았습니다 Shizuku가 실행되고 있지 않습니다 권한 필요 준비됨 권한 부여 자동 설치를 활성화하려면 Shizuku를 설치하세요 자동 설치를 활성화하려면 Shizuku를 시작하세요 Shizuku 설치 실패, 표준 설치 프로그램 사용 앱 자동 업데이트 Shizuku를 통해 백그라운드에서 자동으로 업데이트 다운로드 및 설치 업데이트 업데이트 확인 주기 백그라운드에서 앱 업데이트를 확인하는 빈도 3시간 6시간 12시간 24시간 링크로 추가 앱을 저장소에 연결 GitHub 저장소에 연결할 설치된 앱을 선택하세요 앱 검색… GitHub 저장소 URL github.com/owner/repo 확인 중… 연결 및 추적 최신 릴리스 확인 중… 확인용 APK 다운로드 중… 서명 키 확인 중… 패키지 이름 불일치: APK는 %1$s이지만 선택된 앱은 %2$s입니다 서명 키 불일치: 이 저장소의 APK는 다른 개발자가 서명했습니다 설치 파일 선택 설치된 앱과 대조할 APK를 선택하세요 다운로드 실패 내보내기 가져오기 앱 가져오기 내보낸 JSON을 붙여넣어 추적 중인 앱을 복원하세요 내보낸 JSON을 여기에 붙여넣기… 사전 릴리스 포함 업데이트 확인 시 사전 릴리스 버전을 추적합니다. 비활성화하면 안정적인 릴리스만 고려됩니다. 앱을 제거하시겠습니까? %1$s을(를) 제거하시겠습니까? 이 작업은 취소할 수 없으며 앱 데이터가 손실될 수 있습니다. 잘못된 GitHub URL입니다. 형식: github.com/owner/repo 저장소를 찾을 수 없음: %1$s/%2$s GitHub API 요청 한도를 초과했습니다. 나중에 다시 시도하세요. 연결 실패: %1$s 설치된 앱을 불러오지 못했습니다 %1$s이(가) %2$s/%3$s에 연결됨 내보내기 실패: %1$s 가져오기 실패: %1$s %1$d개의 앱을 가져왔습니다 , %1$d개 건너뜀 , %1$d개 실패 서명 키가 변경됨 이 앱의 서명 인증서가 처음 설치된 이후 변경되었습니다.\n\n개발자가 서명 키를 교체했거나 바이너리가 변조되었을 수 있습니다.\n\n예상: %1$s\n수신: %2$s 그래도 설치 검증된 빌드 확인 중\u2026 에셋 에셋 없음 이 릴리스에 연관된 에셋이 없습니다 에셋 옵션 선택 여러 에셋 사용 가능 이 릴리스에 여러 설치 가능한 파일이 있습니다. 목록을 검토하고 기기에 맞는 파일을 선택하세요. 정보 재시도 자동 감지: %1$s 언어 선택 패키지 불일치: APK는 %1$s이지만 설치된 앱은 %2$s입니다. 업데이트가 차단되었습니다. 서명 키 불일치: 업데이트가 다른 개발자에 의해 서명되었습니다. 업데이트가 차단되었습니다. 리퀴드 글라스 효과 매끄러운 유리 같은 외관으로 인터페이스를 향상시킵니다 본 저장소 숨기기 이미 본 저장소를 디스커버리 피드에서 숨깁니다 조회 기록 삭제 모든 조회 기록을 초기화하여 피드에 다시 표시합니다 조회 기록이 삭제되었습니다 확인함 ================================================ FILE: core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml ================================================ GitHub Store Zainstalowane aplikacje Cofnij Sprawdź aktualizacje Nie można uruchomić %1$s Nie udało się otworzyć %1$s Nie udało się zaktualizować %1$s: %2$s Aktualizacja nie powiodła się Aktualizacja wszystkich nie powiodła się: %1$s Wszystkie aplikacje zostały pomyślnie zaktualizowane Brak dostępnych aktualizacji Przeszukaj swoje aplikacje Nie znaleziono aplikacji Aktualizuj wszystko Aktualizuj Otwórz Anuluj Sprawdzanie… Zaktualizowano pomyślnie Błąd: %1$s Aktualizowanie %1$d z %2$d Obecnie: %1$s Oczekiwanie na autoryzację… Zalogowano! Możesz już korzystać z aplikacji. Przekierowywanie… Spróbuj ponownie Błąd: %1$s Wprowadź ten kod na GitHubie: Kopiuj kod Otwórz GitHub Odblokuj pełnię\nmożliwości Więcej zapytań Zaloguj się, aby uzyskać wyższe limity API i uniknąć przerw w działaniu. Zaloguj się przez GitHub Anulowano Nieznany błąd Język: Odkrywaj repozytoria Szukaj repozytorium, opisu… Filtruj według języka Znaleziono %1$d wyników Sortuj według Zamknij Najwięcej gwiazdek Najwięcej forków Najlepsze dopasowanie Malejąco Rosnąco Sortuj Wszystkie języki Kotlin Java JavaScript TypeScript Python Swift Rust Go C# C++ C Dart Ruby PHP Wyszukiwanie nie powiodło się Nie znaleziono repozytoriów Profil WYGLĄD O APLIKACJI SIEĆ Kolor motywu Motyw AMOLED Black Czyste czarne tło dla trybu ciemnego Wybrany kolor: %1$s Wersja Pomoc i wsparcie Wyloguj się Wylogowano pomyślnie, przekierowywanie... Pamięć podręczna wyczyszczona pomyślnie Ostrzeżenie! Czy na pewno chcesz się wylogować? Dynamiczny Ocean Fioletowy Las Łupek Bursztyn Otwórz repozytorium Otwórz w przeglądarce Anuluj pobieranie Pokaż opcje instalacji Zgłoś problem Błąd podczas ładowania szczegółów Ponów Brak opisu. Brak informacji o wydaniu. O tej aplikacji Logi instalacji Autor Co nowego Zainstalowano Dostępna aktualizacja Niedostępne Zainstaluj najnowszą Zainstaluj ponownie Aktualizuj aplikację Pobieranie Aktualizowanie Weryfikowanie Instalowanie Otwórz w Obtainium Zarządzaj aktualizacjami automatycznie Sprawdź w AppManager Sprawdź uprawnienia, trackery i bezpieczeństwo Profil Forki Gwiazdki Zgłoszenia autor: %1$s • Zainstalowana: %1$s Architektura zgodna Aktualizuj do %1$s Nie udało się załadować szczegółów Instalator został zapisany w folderze Pobrane Rozpoczęto pobieranie Pobrano Rozpoczęto aktualizację Zainstalowano Zaktualizowano Anulowano Rozpoczęto instalację Błąd Błąd: %1$s Przygotowywanie dla AppManager Otwarto w AppManager Uprawnienie do instalacji zablokowane przez politykę urządzenia Otwarto w zewnętrznym instalatorze Uprawnienie do instalacji niedostępne APK został pomyślnie pobrany, ale to urządzenie nie pozwala na bezpośrednią instalację. Czy chcesz otworzyć go za pomocą zewnętrznego instalatora? Otwórz za pomocą zewnętrznego instalatora Użyj aplikacji innej firmy do zainstalowania APK Błąd: %1$s Typ pliku .%1$s nie jest obsługiwany Nie znaleziono pobranego pliku Na czasie Gorące wydanie Najpopularniejsze Wyszukiwanie repozytoriów... Ładowanie więcej... Brak kolejnych repozytoriów Ponów Nie udało się załadować repozytoriów Zobacz szczegóły zaktualizowano przed chwilą zaktualizowano %1$d godz. temu zaktualizowano wczoraj zaktualizowano %1$d dni temu zaktualizowano %1$s Limit zapytań przekroczony Wykorzystałeś wszystkie zapytania API (%1$d). Wykorzystałeś wszystkie darmowe zapytania API (%1$d). Reset za %1$d min 💡 Zaloguj się, aby uzyskać 5000 zapytań na godzinę zamiast 60! Zaloguj się OK Zamknij Czcionka systemowa Dopasuj czcionkę do urządzenia dla lepszej czytelności Jasny Ciemny Systemowy Repozytorium dodane do ulubionych Repozytorium usunięte z ulubionych Dodaj do ulubionych Usuń z ulubionych Ulubione dodano przed chwilą dodano %1$d godz. temu dodano wczoraj dodano %1$d dni temu dodano %1$s Oznaczone gwiazdką repozytoria Repozytorium jest oznaczone gwiazdką Repozytorium nie jest oznaczone gwiazdką Możesz oznaczyć repozytorium gwiazdką na GitHubie Możesz usunąć gwiazdkę z repozytorium na GitHubie Wymagane logowanie Brak oznaczonych repozytoriów Zaloguj się przez GitHub, aby zobaczyć oznaczone repozytoria Oznacz repozytoria z instalowalnymi wydaniami na GitHubie Ostatnia synchronizacja Przed chwilą %1$d min temu %1$d h temu %1$d d temu Zamknij Nie udało się zsynchronizować oznaczonych gwiazdką repozytoriów Profil dewelopera Otwórz profil dewelopera Nie udało się załadować repozytoriów Nie udało się załadować profilu Repozytoria Obserwujący Obserwowani Szukaj repozytoriów… Wyczyść wyszukiwanie Wszystkie Z wydaniami Zainstalowane Ulubione Sortuj Ostatnio zaktualizowane Nazwa repozytorium repozytoriów Wyświetlanie %1$d z %2$d repozytoriów Brak repozytoriów z instalowalnymi wydaniami Brak zainstalowanych repozytoriów Brak ulubionych repozytoriów Zaktualizowano %1$s Ma wydanie %1$d lat temu %1$d mies temu %1$d d temu %1$d h temu %1$d min temu %1$dM %1$dk Wydano przed chwilą Wydano %1$d godzin(y) temu Wydano wczoraj Wydano %1$d dzień/dni temu Wydano %1$s Strona główna Szukaj Aplikacje Profil Fork Stabilna Wersja przedpremierowa Wszystkie Wybierz wersję Przedpremierowa Nie wybrano wersji Wersje Oczekuje na instalację Odinstaluj Otwórz Obniżenie wersji wymaga odinstalowania Instalacja wersji %1$s wymaga odinstalowania bieżącej wersji (%2$s). Dane aplikacji zostaną utracone. Najpierw odinstaluj Zainstaluj %1$s Nie udało się otworzyć %1$s Nie udało się odinstalować %1$s Najnowsza Ostatnio sprawdzono: %1$s Nigdy nie sprawdzano właśnie teraz %1$d min temu %1$d godz. temu Sprawdzanie aktualizacji… Typ proxy Brak Systemowy HTTP SOCKS Host Port Nazwa użytkownika (opcjonalnie) Hasło (opcjonalnie) Zapisz Proxy Ustawienia proxy zostały zapisane Używa ustawień proxy urządzenia Port musi być z zakresu 1–65535 Połączenie bezpośrednie, bez proxy Nie udało się zapisać ustawień proxy Host proxy jest wymagany Nieprawidłowy port proxy Pokaż hasło Ukryj hasło Śledź tę aplikację Aplikacja dodana do listy śledzonych Nie udało się śledzić aplikacji: %1$s Aplikacja jest już śledzona Zaloguj się przez GitHub Odblokuj pełnię możliwości. Zarządzaj aplikacjami, synchronizuj preferencje i przeglądaj szybciej. Repozytoria Zaloguj się Twoje repozytoria oznaczone gwiazdką na GitHubie Twoje ulubione repozytoria zapisane lokalnie Sesja wygasła Twoja sesja GitHub wygasła lub token został unieważniony. Zaloguj się ponownie, aby kontynuować korzystanie z funkcji wymagających uwierzytelnienia. Możesz nadal przeglądać jako gość z ograniczoną liczbą zapytań API. Zaloguj się ponownie Kontynuuj jako gość Spowoduje to wyczyszczenie lokalnej sesji i danych z pamięci podręcznej. Aby całkowicie cofnąć dostęp, odwiedź GitHub Settings > Applications. Kod wygasa za %1$s Kod urządzenia wygasł. Spróbuj zalogować się ponownie, aby uzyskać nowy kod. Sprawdź połączenie internetowe i spróbuj ponownie. Odrzuciłeś żądanie autoryzacji. Spróbuj ponownie, jeśli było to niezamierzone. Czytaj więcej Pokaż mniej Udostępnij repozytorium Nie udało się udostępnić linku Link skopiowany do schowka Tłumacz Tłumaczenie… Pokaż oryginał Przetłumaczono na %1$s Tłumacz na… Szukaj języka Zmień język Tłumaczenie nie powiodło się. Spróbuj ponownie. Otwórz link GitHub Wykryto link GitHub w schowku Automatyczne wykrywanie linków ze schowka Automatycznie wykrywaj linki GitHub ze schowka przy otwieraniu wyszukiwania Wykryte linki Otwórz w aplikacji Nie znaleziono linku GitHub w schowku Przechowywanie Wyczyść pamięć podręczną Aktualny rozmiar: Wyczyść Wesprzyj GitHub Store Wesprzyj projekt Zbudowane z pasją,\nutrzymywane kawą GitHub Store osiągnął ponad 130 000 pobrań i 7 700 gwiazdek na GitHub — 100% darmowy, bez reklam i śledzenia. Tworzę i utrzymuję ten projekt samodzielnie podczas kończenia szkoły średniej. Głosuj na GitHub Store! GitHub Store został nominowany do Golden Kodee Awards na KotlinConf 2026. 1. Zarejestruj się 2. Głosuj Głosowanie kończy się 22 marca 1. Zarejestruj się na platformie (Kontynuuj z Google) 2. Kliknij Głosuj poniżej 3. Znajdź Usmon Narzullayev i kliknij Głosuj GitHub Sponsors Wsparcie jednorazowe lub cykliczne przez GitHub Buy Me a Coffee Szybkie jednorazowe wsparcie INNE SPOSOBY POMOCY Dodaj gwiazdkę repozytorium Pomaga innym odkryć GitHub Store Zgłoś błąd Poprawia aplikację dla wszystkich Udostępnij znajomym Powiedz o tym innym programistom Każde wsparcie — finansowe lub nie — utrzymuje ten projekt przy życiu. Dziękuję! Instalacja Domyślny Standardowe okno instalacji systemowej Shizuku Cicha instalacja bez potwierdzeń Shizuku nie jest zainstalowany Shizuku nie jest uruchomiony Wymagane uprawnienie Gotowy Przyznaj uprawnienie Zainstaluj Shizuku, aby włączyć cichą instalację Uruchom Shizuku, aby włączyć cichą instalację Instalacja przez Shizuku nie powiodła się, używam standardowego instalatora Automatyczna aktualizacja Automatycznie pobieraj i instaluj aktualizacje w tle przez Shizuku Aktualizacje Częstotliwość sprawdzania Jak często sprawdzać aktualizacje aplikacji w tle 3g 6g 12g 24g Dodaj przez link Połącz aplikację z repozytorium Wybierz zainstalowaną aplikację, aby połączyć ją z repozytorium GitHub Szukaj aplikacji… URL repozytorium GitHub github.com/owner/repo Weryfikacja… Połącz i śledź Sprawdzanie najnowszego wydania… Pobieranie APK do weryfikacji… Weryfikacja klucza podpisu… Niezgodność nazwy pakietu: APK to %1$s, ale wybrana aplikacja to %2$s Niezgodność klucza podpisu: APK z tego repozytorium został podpisany przez innego programistę Wybierz instalator Wybierz APK do weryfikacji z zainstalowaną aplikacją Pobieranie nie powiodło się Eksportuj Importuj Importuj aplikacje Wklej wyeksportowany JSON, aby przywrócić śledzone aplikacje Wklej wyeksportowany JSON tutaj… Uwzględnij wersje wstępne Śledź wersje wstępne podczas sprawdzania aktualizacji. Po wyłączeniu uwzględniane są tylko stabilne wydania. Odinstalować aplikację? Czy na pewno chcesz odinstalować %1$s? Tej czynności nie można cofnąć, a dane aplikacji mogą zostać utracone. Nieprawidłowy URL GitHub. Użyj formatu: github.com/owner/repo Repozytorium nie znaleziono: %1$s/%2$s Przekroczono limit zapytań GitHub API. Spróbuj później. Nie udało się połączyć: %1$s Nie udało się załadować zainstalowanych aplikacji %1$s połączono z %2$s/%3$s Eksport nie powiódł się: %1$s Import nie powiódł się: %1$s Zaimportowano %1$d aplikacji , %1$d pominięto , %1$d nie powiodło się Klucz podpisu zmieniony Certyfikat podpisu tej aplikacji zmienił się od pierwszej instalacji.\n\nMoże to oznaczać, że programista zmienił klucz podpisu lub plik binarny mógł zostać zmodyfikowany.\n\nOczekiwano: %1$s\nOtrzymano: %2$s Zainstaluj mimo to Zweryfikowana kompilacja Sprawdzanie\u2026 Zasoby Brak zasobów Brak zasobów powiązanych z tym wydaniem Wybierz opcję zasobu Dostępnych wiele zasobów Dla tego wydania dostępnych jest wiele plików do zainstalowania. Przejrzyj listę i wybierz odpowiedni dla swojego urządzenia. Informacje Ponów Wykryto automatycznie: %1$s Wybierz język Niezgodność pakietu: APK to %1$s, ale zainstalowana aplikacja to %2$s. Aktualizacja zablokowana. Niezgodność klucza podpisu: aktualizacja została podpisana przez innego programistę. Aktualizacja zablokowana. Efekt płynnego szkła Ulepsz interfejs o gładki, szklany wygląd Ukryj przeglądane repozytoria Ukryj repozytoria, które już przeglądałeś, z kanałów odkrywania Wyczyść historię przeglądania Zresetuj wszystkie przeglądane repozytoria, aby ponownie pojawiły się w kanałach Historia przeglądania wyczyszczona Przeglądane ================================================ FILE: core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml ================================================ GitHub Store Установленные приложения Назад Проверить обновления Не удалось запустить %1$s Не удалось открыть %1$s Не удалось обновить %1$s: %2$s Ошибка обновления Ошибка обновления всех: %1$s Все приложения успешно обновлены Обновлений нет Поиск приложений Приложения не найдены Обновить все Обновить Открыть Отмена Проверка… Успешно обновлено Ошибка: %1$s Обновление %1$d из %2$d Сейчас: %1$s %1$d%% Ожидание авторизации… Вход выполнен! Теперь вы можете использовать приложение. Перенаправление… Попробовать снова Ошибка: %1$s Введите этот код на GitHub: Скопировать код Открыть GitHub Откройте полный\nдоступ Больше запросов Войдите, чтобы получить более высокий лимит API и избежать прерываний. Войти через GitHub Отменено Неизвестная ошибка Язык: Поиск репозиториев Поиск по репозиторию, описанию… Фильтр по языку Найдено результатов: %1$d Сортировать по Закрыть Больше звёзд Больше форков Лучшее совпадение По убыванию По возрастанию Сортировать Все языки Kotlin Java JavaScript TypeScript Python Swift Rust Go C# C++ C Dart Ruby PHP Поиск не удался Репозитории не найдены Профиль ВНЕШНИЙ ВИД О ПРИЛОЖЕНИИ СЕТЬ Цвет темы AMOLED чёрная тема Чёрный фон для тёмного режима Выбранный цвет: %1$s Версия Помощь и поддержка Выйти Вы успешно вышли, перенаправление... Кэш успешно очищен Внимание! Вы уверены, что хотите выйти? Динамическая Океан Фиолетовая Лесная Сланцевая Янтарная Открыть репозиторий Отменить загрузку Ошибка загрузки данных Повторить Описание отсутствует. Об этом приложении Журнал установки Автор Что нового Установлено Доступно обновление Недоступно Установить последнюю Переустановить Обновить приложение Сообщить о проблеме Загрузка Обновление Проверка Установка Открыть в Obtainium Автоматическое управление обновлениями Проверить в AppManager Разрешения, трекеры и безопасность Профиль Форки Звёзды Проблемы от %1$s • Установлено: %1$s Совместимо с архитектурой Обновить до %1$s Нет заметок о выпуске Открыть в браузере Показать параметры установки Не удалось загрузить данные Установщик сохранён в папку Загрузки Загрузка начата Загружено Обновление начато Установлено Обновлено Отменено Установка началась Ошибка Ошибка: %1$s Подготовка для AppManager Открыто в AppManager Разрешение на установку заблокировано политикой устройства Открыто во внешнем установщике Разрешение на установку недоступно APK был успешно загружен, но это устройство не разрешает прямую установку. Хотите открыть его с помощью внешнего установщика? Открыть во внешнем установщике Использовать стороннее приложение для установки APK Ошибка: %1$s Тип файла .%1$s не поддерживается Загруженный файл не найден В тренде Горячий релиз Самые популярные Поиск репозиториев... Загрузка... Больше репозиториев нет Повторить Не удалось загрузить репозитории Подробнее обновлено только что обновлено %1$d ч. назад обновлено вчера обновлено %1$d дн. назад обновлено %1$s Превышен лимит запросов Вы использовали все %1$d API-запросов. Вы использовали все %1$d бесплатных API-запросов. Сброс через %1$d мин 💡 Войдите, чтобы получить 5 000 запросов в час вместо 60! Войти ОК Закрыть Системный шрифт Используйте шрифт вашего устройства для лучшей читаемости Светлая Тёмная Системная Репозиторий добавлен в избранное Репозиторий удалён из избранного Добавить в избранное Удалить из избранного Избранное только что добавлено добавлено %1$d час(ов) назад добавлено вчера добавлено %1$d дн. назад добавлено %1$s Избранные репозитории Репозиторий добавлен в избранное Репозиторий не в избранном Вы можете добавить репозиторий в избранное на GitHub Вы можете убрать репозиторий из избранного на GitHub Требуется вход Нет избранных репозиториев Войдите через GitHub, чтобы увидеть избранные репозитории Отмечайте репозитории с установочными релизами на GitHub Последняя синхронизация Только что %1$d мин назад %1$d ч назад %1$d д назад Закрыть Не удалось синхронизировать избранные репозитории Профиль разработчика Открытый профиль разработчика Не удалось загрузить репозитории Не удалось загрузить профиль Репозитории Подписчики Подписки Поиск репозиториев… Очистить поиск Все С релизами Установленные Избранные Сортировка Недавно обновлённые Название репозиторий репозиториев Показано %1$d из %2$d репозиториев Нет репозиториев с устанавливаемыми релизами Нет установленных репозиториев Нет избранных репозиториев Обновлено %1$s Есть релиз %1$d г %1$d мес %1$d д %1$d ч %1$d мин %1$dM %1$dk Опубликовано только что Опубликовано %1$d час(ов) назад Опубликовано вчера Опубликовано %1$d день(дней) назад Опубликовано %1$s Главная Поиск Приложения Профиль Форк Стабильная Предварительный релиз Все Выбрать версию Предрелиз Версия не выбрана Версии Ожидает установки Удалить Открыть Для понижения версии требуется удаление Для установки версии %1$s необходимо сначала удалить текущую версию (%2$s). Данные приложения будут потеряны. Сначала удалить Установить %1$s Не удалось открыть %1$s Не удалось удалить %1$s Последняя Последняя проверка: %1$s Не проверялось только что %1$d мин назад %1$d ч назад Проверка обновлений… Тип прокси Нет Системный HTTP SOCKS Хост Порт Имя пользователя (необязательно) Пароль (необязательно) Сохранить прокси Настройки прокси сохранены Использует прокси-настройки устройства Порт должен быть 1–65535 Прямое подключение, без прокси Не удалось сохранить настройки прокси Требуется хост прокси Недопустимый порт прокси Показать пароль Скрыть пароль Отслеживать приложение Приложение добавлено в список отслеживания Не удалось отследить приложение: %1$s Приложение уже отслеживается Войти через GitHub Откройте полный доступ. Управляйте приложениями, синхронизируйте настройки и просматривайте быстрее. Репозитории Войти Ваши избранные репозитории на GitHub Ваши избранные репозитории, сохранённые локально Сессия истекла Ваша сессия GitHub истекла или токен был отозван. Пожалуйста, войдите снова для продолжения использования авторизованных функций. Вы можете продолжить просмотр как гость с ограниченным количеством API-запросов. Войти снова Продолжить как гость Это очистит вашу локальную сессию и кэшированные данные. Чтобы полностью отозвать доступ, перейдите в GitHub Settings > Applications. Код истекает через %1$s Срок действия кода устройства истёк. Пожалуйста, попробуйте войти снова для получения нового кода. Проверьте подключение к интернету и попробуйте снова. Вы отклонили запрос авторизации. Попробуйте снова, если это было непреднамеренно. Читать далее Свернуть Поделиться репозиторием Не удалось поделиться ссылкой Ссылка скопирована в буфер обмена Перевести Перевод… Показать оригинал Переведено на %1$s Перевести на… Поиск языка Изменить язык Ошибка перевода. Попробуйте ещё раз. Открыть ссылку GitHub Обнаружена ссылка GitHub в буфере обмена Автоопределение ссылок из буфера Автоматически определять ссылки GitHub из буфера обмена при открытии поиска Обнаруженные ссылки Открыть в приложении Ссылка GitHub не найдена в буфере обмена Хранение Очистить кэш Текущий размер: Очистить Поддержать GitHub Store Поддержать проект Создано с любовью,\nподдерживается кофе GitHub Store достиг более 130 000 загрузок и 7 700 звёзд на GitHub — 100% бесплатно, без рекламы и отслеживания. Я разработал и поддерживаю этот проект полностью самостоятельно, заканчивая школу. Ваша поддержка — даже небольшая — помогает оплачивать инфраструктуру и развивать приложение. Голосуйте за GitHub Store! GitHub Store номинирован на Golden Kodee Awards на KotlinConf 2026. 1. Зарегистрироваться 2. Проголосовать Голосование до 22 марта 1. Зарегистрируйтесь на платформе (Войти через Google) 2. Нажмите «Голосовать» ниже 3. Найдите Usmon Narzullayev и нажмите «Голосовать» GitHub Sponsors Регулярная или разовая поддержка через GitHub Buy Me a Coffee Быстрая разовая поддержка ДРУГИЕ СПОСОБЫ ПОМОЧЬ Поставить звезду репозиторию Помогает другим найти GitHub Store Сообщить об ошибке Делает приложение лучше Поделиться с друзьями Расскажите другим разработчикам Любая поддержка — финансовая или нет — помогает проекту жить. Спасибо! Установка По умолчанию Стандартный системный диалог установки Shizuku Тихая установка без подтверждений Shizuku не установлен Shizuku не запущен Требуется разрешение Готов Предоставить разрешение Установите Shizuku для тихой установки Запустите Shizuku для тихой установки Установка через Shizuku не удалась, используется стандартный установщик Автообновление приложений Автоматически загружать и устанавливать обновления в фоне через Shizuku Обновления Интервал проверки обновлений Как часто проверять обновления приложения в фоне 12ч 24ч Добавить по ссылке Привязать приложение к репозиторию Выберите установленное приложение для привязки к репозиторию GitHub Поиск приложений… URL репозитория GitHub github.com/owner/repo Проверка… Привязать и отслеживать Проверка последнего релиза… Загрузка APK для проверки… Проверка ключа подписи… Несоответствие имени пакета: APK — %1$s, а выбранное приложение — %2$s Несоответствие ключа подписи: APK в этом репозитории подписан другим разработчиком Выберите установщик Выберите APK для проверки соответствия установленному приложению Ошибка загрузки Экспорт Импорт Импорт приложений Вставьте экспортированный JSON для восстановления отслеживаемых приложений Вставьте экспортированный JSON… Включить пре-релизы Отслеживать пре-релизные версии при проверке обновлений. При отключении учитываются только стабильные релизы. Удалить приложение? Вы уверены, что хотите удалить %1$s? Это действие нельзя отменить, данные приложения могут быть утеряны. Неверный URL GitHub. Используйте формат: github.com/owner/repo Репозиторий не найден: %1$s/%2$s Превышен лимит запросов GitHub API. Попробуйте позже. Не удалось привязать: %1$s Не удалось загрузить установленные приложения %1$s привязано к %2$s/%3$s Ошибка экспорта: %1$s Ошибка импорта: %1$s Импортировано %1$d приложений , %1$d пропущено , %1$d с ошибкой Ключ подписи изменён Сертификат подписи этого приложения изменился с момента первой установки.\n\nЭто может означать, что разработчик сменил ключ подписи, или бинарный файл был изменён.\n\nОжидалось: %1$s\nПолучено: %2$s Всё равно установить Проверенная сборка Проверка\u2026 Ресурсы Нет ресурсов Нет ресурсов, связанных с этим релизом Выбрать вариант ресурса Доступно несколько ресурсов Для этого релиза доступно несколько устанавливаемых файлов. Просмотрите список и выберите подходящий для вашего устройства. Информация Повторить Автоопределение: %1$s Выбрать язык Несоответствие пакета: APK — %1$s, но установленное приложение — %2$s. Обновление заблокировано. Несоответствие ключа подписи: обновление подписано другим разработчиком. Обновление заблокировано. Эффект жидкого стекла Улучшите интерфейс с помощью гладкого стеклянного оформления Скрыть просмотренные репозитории Скрыть уже просмотренные репозитории из лент обнаружения Очистить историю просмотров Сбросить все просмотренные репозитории, чтобы они снова появились в лентах История просмотров очищена Просмотрено ================================================ FILE: core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml ================================================ GitHub Store Yüklü Uygulamalar Geri git Güncellemeleri kontrol et %1$s çalıştırılamadı %1$s açılamadı %1$s: %2$s güncellenemedi Güncelleme başarısız Tümünü güncelleme başarısız: %1$s Tüm uygulamalar başarılı şekilde güncellendi Güncelleme yok Uygulamalarınızı arayın Uygulama Bulunamadı Tümünü Güncelle Güncelle İptal et Kontrol ediliyor… Başarıyla güncellendi Hata: %1$s %2$d içinden %1$d güncelleniyor Şu anda: %1$s Yetki bekleniyor Giriş Yapıldı! Artık uygulamayı kullanabilirsiniz. Yönlendiriliyor... Tekrar dene Hata: %1$s Kodu GitHub'da gir Kodu kopyala Github'ı aç Tam Deneyimi Keşfedin Daha Fazla İstek Daha yüksek API hız sınırları elde etmek ve kesintileri önlemek için oturum açın. Github ile giriş yap İptal edildi Bilinmeyen hata Dil: Repoları keşfet Repo, açıklama ara... Dil ile filtrele %1$d sonuç bulundu Sırala Kapat En Çok Yıldız En Çok Fork En İyi Eşleşme Azalan Artan Sırala Tüm Diller Kotlin Java JavaScript TypeScript Python Swift Rust Go C# C++ C Dart Ruby PHP Arama Başarısız Repo bulunamadı Profil GÖRÜNÜM HAKKINDA Tema Rengi AMOLED Siyah Tema Karanlık mod için saf siyah arka plan Seçilmiş renk: %1$s Sürüm Yardım & Destek Çıkış Yap Başarılı şekilde çıkış yapıldı, yönlendiriliyor... Önbellek başarıyla temizlendi Uyarı! Çıkmak istediğinden emin misin? Dinamik Deniz Mor Orman Arduvaz Amber Repoyu Aç Tarayıcıda aç İndirmeyi iptal et Yükleme seçeneklerini göster Detaylar yüklenirken hata Tekrar dene Açıklama sağlanmadı. Sürüm notu yok Sorun bildir Bu uygulama hakkında Yükleme günlükleri Yazar Neler Yeni Yüklü Güncelleme mevcut Mevcut değil En son sürümü yükle Tekrar yükle Uygulamayı güncelle İndiriliyor Güncelleniyor Doğrulanıyor Yükleniyor Obtainium'da aç Uygulamaları otomatik güncelle AppManager ile incele İzinleri, izleyici ve güvenliği kontrol et Profil Forklar Yıldızlar Sorunlar %1$s Yüklenmiş: %1$s Mimari uyumlu %1$s'e güncelle Detaylar yüklenemedi Yükleyici, İndirilenler klasörüne kaydedildi İndirme başladı İndirildi Güncelleme başladı Yüklendi Güncellendi İptal edildi Yükleme Başladı Hata Hata: %1$s AppManager için hazırlanıyor AppManager\'da açıldı Yükleme izni cihaz politikası tarafından engellendi Harici yükleyicide açıldı Yükleme izni kullanılamıyor APK başarıyla indirildi ancak bu cihaz doğrudan yüklemeye izin vermiyor. Harici bir yükleyici ile açmak ister misiniz? Harici yükleyici ile aç APK\'yı yüklemek için üçüncü taraf bir uygulama kullanın Hata: %1$s Tür .%1$s desteklenmiyor İndirilen dosya bulunamadı Trendler Yeni sürüm En popüler Repolar bulunuyor... Daha fazla yükleniyor... Başka repo yok Tekrar dene Repolar yüklenemedi Detayları Görüntüle şimdi güncellendi %1$d saat önce güncellendi dün güncellendi %1$d gün önce güncellendi %1$s tarihinde güncellendi Hız Sınırı Aşıldı Tüm %1$d API isteklerini kullandınız. Tüm %1$d ücretsiz API isteğinizi kullandınız. %1$d dakika içinde yenilenir 💡 Saat başı 60 yerine 5.000 istek için giriş yapın! Giriş Yap Tamam Kapat Sistem fontu Daha iyi okunabilirlik için cihazınızın yazı tipini eşleştirin Aydınlık Karanlık Sistem Repo favorilere eklendi Repo favorilerden kaldırıldı Favorilere ekle Favorilerden kaldır Favoriler şimdi eklendi %1$d saat önce eklendi dün eklendi %1$d gün önce eklendi %1$s tarihinde eklendi Yıldızlı Repolar Repo yıldızlandı Repo yıldızlanmadı GitHub'dan repo yıldızlayabilirsiniz GitHub'dan repo yıldız kaldırabilirsiniz Giriş gerekli GitHub ile oturum açarak yıldızlı repolarınızı görüntüleyin Yıldızlı repo yok Yüklenebilir sürümlü repoları görmek için GitHub'da yıldızlayın Son eşitleme Şimdi %1$d dakika önce %1$d saat önce %1$d gün önce Kapat Yıldızlı repoları eşitlerken hata Geliştirici Profili Geliştirici profilini aç Repolar yüklenirken hata Profil yüklenirken hata Repolar Takipçiler Takip edilenler Repo ara… Aramayı temizle Hepsi Yayınlanmış İndirilmiş Favoriler Sırala Yeni Güncellenmiş Ad repo repolar %2$d repodan %1$d tanesi gösteriliyor Yüklenebilir sürümleri olan repo yok Yüklü repo yok Favori repo yok %1$s güncellendi Yayınlanmış %1$d y önce %1$d ay önce %1$d g önce %1$d s önce %1$d d önce %1$dM %1$dk Az önce yayınlandı %1$d saat önce yayınlandı Dün yayınlandı %1$d gün önce yayınlandı %1$s tarihinde yayınlandı Ana Sayfa Ara Uygulamalar Profil Çatalla Kararlı Ön sürüm Tümü Sürüm seç Ön sürüm Sürüm seçilmedi Sürümler Kurulum bekleniyor Kaldır Sürüm düşürme kaldırma gerektirir %1$s sürümünü yüklemek için önce mevcut sürümü (%2$s) kaldırmanız gerekir. Uygulama verileri kaybolacaktır. Önce kaldır %1$s yükle %1$s açılamadı %1$s kaldırılamadı En son Son kontrol: %1$s Hiç kontrol edilmedi az önce %1$d dk önce %1$d sa önce Güncellemeler kontrol ediliyor… Proxy Türü Yok Sistem HTTP SOCKS Ana Bilgisayar Port Kullanıcı adı (isteğe bağlı) Şifre (isteğe bağlı) Proxy'yi Kaydet Proxy ayarları kaydedildi Cihazınızın proxy ayarlarını kullanır Port 1–65535 arası olmalı Doğrudan bağlantı, proxy yok Proxy ayarları kaydedilemedi Proxy ana bilgisayarı gerekli Geçersiz proxy portu Şifreyi göster Şifreyi gizle Bu uygulamayı izle Uygulama izleme listesine eklendi Uygulama izlenemedi: %1$s Uygulama zaten izleniyor GitHub ile giriş yap Tam deneyimi keşfedin. Uygulamalarınızı yönetin, tercihlerinizi senkronize edin ve daha hızlı gezinin. Repolar Giriş yap GitHub'daki yıldızlı repolarınız Yerel olarak kaydedilen favori repolarınız Oturum Süresi Doldu GitHub oturumunuzun süresi doldu veya token iptal edildi. Kimliği doğrulanmış özellikleri kullanmaya devam etmek için lütfen tekrar giriş yapın. Sınırlı API istekleriyle misafir olarak gezinmeye devam edebilirsiniz. Tekrar Giriş Yap Misafir olarak devam et Bu işlem yerel oturumunuzu ve önbellek verilerinizi temizleyecektir. Erişimi tamamen iptal etmek için GitHub Settings > Applications sayfasını ziyaret edin. Kodun süresi %1$s sonra dolacak Cihaz kodunun süresi doldu. Yeni bir kod almak için lütfen tekrar giriş yapmayı deneyin. Lütfen internet bağlantınızı kontrol edin ve tekrar deneyin. Yetkilendirme isteğini reddettiniz. İstemeden yaptıysanız tekrar deneyin. Devamını oku Daha az göster Depoyu paylaş Bağlantı paylaşılamadı Bağlantı panoya kopyalandı Çevir Çevriliyor… Orijinali göster %1$s diline çevrildi Şuna çevir… Dil ara Dili değiştir Çeviri başarısız. Lütfen tekrar deneyin. GitHub bağlantısını aç Panoda GitHub bağlantısı algılandı Pano bağlantılarını otomatik algıla Arama açılırken panodan GitHub bağlantılarını otomatik olarak algıla Algılanan bağlantılar Uygulamada aç Panoda GitHub bağlantısı bulunamadı Depolama Önbelleği Temizle Geçerli boyut: Temizle GitHub Store'u Destekle Projeyi destekle Sevgiyle yapıldı,\nkahveyle sürdürüldü GitHub Store 130.000+ indirme ve 7.700+ GitHub yıldızına ulaştı — %100 ücretsiz, reklamsız ve izleme yok. Bu projeyi liseyi bitirirken tamamen tek başıma geliştiriyorum ve sürdürüyorum. GitHub Store için oy ver! GitHub Store KotlinConf 2026 Golden Kodee Awards için aday gösterildi. 1. Kayıt ol 2. Oy ver Oylama 22 Mart'ta kapanıyor 1. Platformda kayıt ol (Google ile devam et) 2. Aşağıdan Oy Ver'e dokun 3. Usmon Narzullayev'i bul ve Oy Ver'e tıkla GitHub Sponsors GitHub üzerinden tek seferlik veya düzenli destek Buy Me a Coffee Hızlı tek seferlik destek YARDIM ETMENİN DİĞER YOLLARI Depoya yıldız ver Başkalarının GitHub Store'u keşfetmesine yardımcı olur Hata bildir Uygulamayı herkes için daha iyi yapar Arkadaşlarınla paylaş Diğer geliştiricilere duyur Her destek — finansal olsun ya da olmasın — bu projeyi yaşatır. Teşekkürler! Kurulum Varsayılan Standart sistem kurulum penceresi Shizuku Onay gerektirmeyen sessiz kurulum Shizuku yüklü değil Shizuku çalışmıyor İzin gerekli Hazır İzin ver Sessiz kurulumu etkinleştirmek için Shizuku\'yu yükleyin Sessiz kurulumu etkinleştirmek için Shizuku\'yu başlatın Shizuku kurulumu başarısız, standart yükleyici kullanılıyor Uygulamaları otomatik güncelle Shizuku aracılığıyla arka planda otomatik olarak güncellemeleri indirin ve yükleyin Güncellemeler Güncelleme kontrol aralığı Arka planda uygulama güncellemelerinin ne sıklıkla kontrol edileceği 3s 6s 12s 24s Bağlantıyla ekle Uygulamayı depoya bağla GitHub deposuna bağlamak için yüklü bir uygulama seçin Uygulama ara… GitHub depo URL\'si github.com/owner/repo Doğrulanıyor… Bağla ve takip et Son sürüm kontrol ediliyor… Doğrulama için APK indiriliyor… İmza anahtarı doğrulanıyor… Paket adı uyuşmuyor: APK %1$s, ancak seçilen uygulama %2$s İmza anahtarı uyuşmuyor: bu depodaki APK farklı bir geliştirici tarafından imzalanmış Yükleyici seçin Yüklü uygulamanızla doğrulamak için APK seçin İndirme başarısız Dışa aktar İçe aktar Uygulamaları içe aktar Takip edilen uygulamaları geri yüklemek için dışa aktarılan JSON\'u yapıştırın Dışa aktarılan JSON\'u buraya yapıştırın… Ön sürümleri dahil et Güncelleme kontrolünde ön sürümleri takip edin. Devre dışı bırakıldığında, yalnızca kararlı sürümler dikkate alınır. Uygulama kaldırılsın mı? %1$s uygulamasını kaldırmak istediğinizden emin misiniz? Bu işlem geri alınamaz ve uygulama verileri kaybolabilir. Geçersiz GitHub URL\'si. Biçim: github.com/owner/repo Depo bulunamadı: %1$s/%2$s GitHub API istek sınırı aşıldı. Daha sonra tekrar deneyin. Bağlama başarısız: %1$s Yüklü uygulamalar yüklenemedi %1$s, %2$s/%3$s ile bağlandı Dışa aktarma başarısız: %1$s İçe aktarma başarısız: %1$s %1$d uygulama içe aktarıldı , %1$d atlandı , %1$d başarısız İmza anahtarı değişti Bu uygulamanın imza sertifikası ilk kurulumdan bu yana değişti.\n\nBu, geliştiricinin imza anahtarını değiştirdiği veya dosyanın değiştirilmiş olabileceği anlamına gelebilir.\n\nBeklenen: %1$s\nAlınan: %2$s Yine de yükle Doğrulanmış yapı Kontrol ediliyor\u2026 Dosyalar Dosya yok Bu sürümle ilişkili dosya yok Dosya seçeneği belirleyin Birden fazla dosya mevcut Bu sürüm için birden fazla kurulabilir dosya mevcut. Listeyi inceleyin ve cihazınıza uygun olanı seçin. Bilgi Tekrar dene Otomatik algılanan: %1$s Dil seçin Paket uyumsuzluğu: APK %1$s, ancak yüklü uygulama %2$s. Güncelleme engellendi. İmza anahtarı uyumsuzluğu: güncelleme farklı bir geliştirici tarafından imzalanmış. Güncelleme engellendi. Sıvı Cam Efekti Arayüzü pürüzsüz cam benzeri bir görünümle geliştirin Görülen depoları gizle Zaten görüntülediğiniz depoları keşif akışlarından gizleyin Görüntüleme geçmişini temizle Tüm görüntülenen depoları sıfırlayarak akışlarda tekrar görünmelerini sağlayın Görüntüleme geçmişi temizlendi Görüntülendi ================================================ FILE: core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml ================================================ GitHub Store 已安装的应用 返回 检查更新 无法启动 %1$s 无法打开 %1$s 更新 %1$s 失败:%2$s 更新失败 全部更新失败:%1$s 所有应用已成功更新 暂无可用更新 搜索应用 未找到应用 全部更新 更新 打开 取消 正在检查… 更新成功 错误:%1$s 正在更新 %1$d / %2$d 当前:%1$s %1$d%% 正在等待授权… 登录成功! 现在可以使用应用。正在跳转… 重试 错误:%1$s 请在 GitHub 上输入此代码: 复制代码 打开 GitHub 解锁完整\n体验 更多请求 登录以获得更高的 API 限额,避免中断。 使用 GitHub 登录 已取消 未知错误 语言: 发现仓库 搜索仓库、描述… 按语言筛选 找到 %1$d 个结果 重试 排序方式 关闭 最多星标 最多分叉 最佳匹配 降序 升序 排序 所有语言 Kotlin Java JavaScript TypeScript Python Swift Rust Go C# C++ C Dart Ruby PHP 搜索失败 未找到仓库 个人资料 外观 关于 网络 主题颜色 AMOLED 黑色主题 深色模式下的纯黑背景 已选择颜色:%1$s 版本 帮助与支持 退出登录 已成功退出,正在跳转… 缓存已成功清除 警告! 确定要退出登录吗? 动态 海洋 紫色 森林 石板 琥珀 加载详情失败 关于此应用 安装日志 作者 更新内容 已安装 有可用更新 安装最新版本 重新安装 正在下载 正在更新 正在验证 正在安装 个人资料 分支 星标 问题 由 %1$s • 已安装:%1$s 架构兼容 更新到 %1$s 报告问题 无法加载详情 安装程序已保存到下载文件夹 开始下载 已下载 开始更新 已安装 已更新 已取消 开始安装 错误 错误:%1$s 正在为 AppManager 准备 已在 AppManager 中打开 安装权限被设备策略阻止 已在外部安装器中打开 安装权限不可用 APK已成功下载,但此设备不允许直接安装。是否使用外部安装器打开? 使用外部安装器打开 使用第三方应用安装APK 错误:%1$s 不支持的文件类型 .%1$s 未找到下载的文件 热门 热门发布 最受欢迎 正在查找仓库… 加载中… 没有更多仓库了 重试 加载仓库失败 查看详情 刚刚更新 %1$d 小时前更新 昨天更新 %1$d 天前更新 %1$s 更新 请求次数已达上限 已用完 %1$d 次 API 请求。 已用完 %1$d 次免费 API 请求。 %1$d 分钟后重置 💡 登录后每小时可使用 5,000 次请求,而不是 60 次! 登录 确定 关闭 系统字体 使用设备字体以提高可读性 浅色 深色 跟随系统 已添加到收藏 已从收藏中移除 添加到收藏 从收藏中移除 收藏 刚刚添加 %1$d 小时前添加 昨天添加 %1$d 天前添加 %1$s 添加 已收藏的仓库 仓库已收藏 仓库未收藏 你可以在 GitHub 上收藏该仓库 你可以在 GitHub 上取消收藏该仓库 需要登录 没有收藏的仓库 使用 GitHub 登录以查看收藏的仓库 在 GitHub 上收藏有可安装版本的仓库以在此查看 上次同步 刚刚 %1$d 分钟前 %1$d 小时前 %1$d 天前 关闭 同步收藏的仓库失败 开发者简介 打开开发者个人资料 加载仓库失败 加载个人资料失败 仓库 关注者 正在关注 搜索仓库… 清除搜索 全部 有发布版 已安装 收藏 排序 最近更新 名称 个仓库 个仓库 显示 %2$d 个中的 %1$d 个仓库 没有可安装发布版的仓库 没有已安装的仓库 没有收藏的仓库 %1$s前更新 有发布版 %1$d 年前 %1$d 个月前 %1$d 天前 %1$d 小时前 %1$d 分钟前 %1$dM %1$dk 刚刚发布 %1$d 小时前发布 昨天发布 %1$d 天前发布 发布于 %1$s 首页 搜索 应用 个人资料 分叉 稳定版 预发布 全部 选择版本 预发布 未选择版本 版本 打开仓库 在浏览器中打开 取消下载 显示安装选项 暂无描述。 暂无发行说明。 不可用 更新应用 等待安装 在 Obtainium 中打开 自动管理更新 使用 AppManager 检查 检查权限、追踪器和安全性 卸载 打开 降级需要先卸载 安装版本 %1$s 需要先卸载当前版本(%2$s)。应用数据将丢失。 先卸载 安装 %1$s 无法打开 %1$s 无法卸载 %1$s 最新 上次检查:%1$s 从未检查 刚刚 %1$d 分钟前 %1$d 小时前 正在检查更新… 代理类型 系统代理 HTTP SOCKS 主机地址 端口 用户名(可选) 密码(可选) 保存代理 代理设置已保存 使用设备的代理设置 端口必须为 1–65535 直连,不使用代理 无法保存代理设置 必须填写代理主机 无效的代理端口 显示密码 隐藏密码 跟踪此应用 应用已添加到跟踪列表 跟踪应用失败:%1$s 该应用已在跟踪中 登录 GitHub 解锁完整体验。管理您的应用、同步偏好设置,更快地浏览。 仓库 登录 您在 GitHub 上收藏的仓库 本地保存的收藏仓库 会话已过期 您的 GitHub 会话已过期或令牌已被撤销。请重新登录以继续使用认证功能。 您仍可以访客身份浏览,但 API 请求次数有限。 重新登录 以访客身份继续 这将清除您的本地会话和缓存数据。要完全撤销访问权限,请访问 GitHub Settings > Applications。 验证码将在 %1$s 后过期 设备验证码已过期。 请重新登录以获取新的验证码。 请检查您的网络连接并重试。 您拒绝了授权请求。如果是误操作,请重试。 阅读更多 收起 分享仓库 无法分享链接 链接已复制到剪贴板 翻译 翻译中… 显示原文 已翻译为%1$s 翻译为… 搜索语言 更改语言 翻译失败,请重试。 打开 GitHub 链接 在剪贴板中检测到 GitHub 链接 自动检测剪贴板链接 打开搜索时自动检测剪贴板中的 GitHub 链接 检测到的链接 在应用中打开 剪贴板中未找到 GitHub 链接 存储 清除缓存 当前大小: 清除 支持 GitHub Store 支持项目 用爱构建,\n用咖啡维护 GitHub Store 已达到 130,000+ 下载和 7,700+ GitHub 星标 —— 100% 免费,无广告,无跟踪。 我在完成高中学业的同时,完全独立开发并维护这个项目。你的支持——哪怕很小——都能帮助保持应用无 bug、支付基础设施费用,并实现你们请求的功能。 为 GitHub Store 投票! GitHub Store 已被提名为 KotlinConf 2026 Golden Kodee Awards。 1. 注册 2. 投票 投票截止:3月22日 1. 在奖项平台注册(使用 Google 登录) 2. 点击下方“投票” 3. 找到 Usmon Narzullayev 并点击投票 GitHub Sponsors 通过 GitHub 的一次性或持续支持 Buy Me a Coffee 快速一次性支持 其他帮助方式 为仓库加星 帮助更多人发现 GitHub Store 报告问题 让应用变得更好 分享给朋友 向其他开发者传播 任何形式的支持——无论是否金钱——都让这个项目继续存在。谢谢! 安装 默认 标准系统安装对话框 Shizuku 无需确认的静默安装 Shizuku 未安装 Shizuku 未运行 需要权限 就绪 授予权限 安装 Shizuku 以启用静默安装 启动 Shizuku 以启用静默安装 Shizuku 安装失败,正在使用标准安装程序 自动更新应用 通过 Shizuku 在后台自动下载并安装更新 更新 更新检查间隔 在后台检查应用更新的频率 3小时 6小时 12小时 24小时 通过链接添加 将应用链接到仓库 选择一个已安装的应用以链接到GitHub仓库 搜索应用… GitHub仓库URL github.com/owner/repo 验证中… 链接并追踪 检查最新版本… 正在下载APK进行验证… 正在验证签名密钥… 包名不匹配:APK为%1$s,但所选应用为%2$s 签名密钥不匹配:此仓库中的APK由不同的开发者签名 选择安装包 选择APK以与已安装的应用进行验证 下载失败 导出 导入 导入应用 粘贴导出的JSON以恢复跟踪的应用 在此粘贴导出的JSON… 包含预发布版本 检查更新时跟踪预发布版本。禁用后,仅考虑稳定版本。 卸载应用? 确定要卸载%1$s吗?此操作无法撤销,应用数据可能会丢失。 无效的GitHub URL。请使用格式:github.com/owner/repo 未找到仓库:%1$s/%2$s GitHub API请求限制已超出。请稍后重试。 链接失败:%1$s 无法加载已安装的应用 %1$s已链接到%2$s/%3$s 导出失败:%1$s 导入失败:%1$s 已导入%1$d个应用 ,%1$d个已跳过 ,%1$d个失败 签名密钥已更改 此应用的签名证书自首次安装以来已更改。\n\n这可能意味着开发者更换了签名密钥,或者二进制文件可能已被篡改。\n\n预期:%1$s\n收到:%2$s 仍然安装 已验证的构建 检查中\u2026 资源 无资源 此版本没有关联的资源 选择资源选项 多个资源可用 此版本有多个可安装文件。请查看列表并选择适合您设备的文件。 信息 重试 自动检测:%1$s 选择语言 包名不匹配:APK 为 %1$s,但已安装的应用为 %2$s。更新已阻止。 签名密钥不匹配:更新由不同的开发者签名。更新已阻止。 液态玻璃效果 以流畅的玻璃质感提升界面外观 隐藏已浏览的仓库 在发现信息流中隐藏你已经浏览过的仓库 清除浏览记录 重置所有已浏览的仓库,使其重新出现在信息流中 浏览记录已清除 已浏览 ================================================ FILE: core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/ExpressiveCard.kt ================================================ package zed.rainxch.core.presentation.components import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CardDefaults import androidx.compose.material3.ElevatedCard import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @Composable fun ExpressiveCard( modifier: Modifier = Modifier, onClick: (() -> Unit)? = null, content: @Composable () -> Unit, ) { if (onClick != null) { ElevatedCard( modifier = modifier.fillMaxWidth(), colors = CardDefaults.elevatedCardColors( containerColor = MaterialTheme.colorScheme.surfaceContainer, ), onClick = onClick, shape = RoundedCornerShape(32.dp), content = { content() }, ) } else { ElevatedCard( modifier = modifier.fillMaxWidth(), colors = CardDefaults.elevatedCardColors( containerColor = MaterialTheme.colorScheme.surfaceContainer, ), shape = RoundedCornerShape(32.dp), content = { content() }, ) } } ================================================ FILE: core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/GitHubStoreImage.kt ================================================ package zed.rainxch.core.presentation.components import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme 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.skydoves.landscapist.coil3.CoilImage import com.skydoves.landscapist.components.rememberImageComponent import com.skydoves.landscapist.crossfade.CrossfadePlugin @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun GitHubStoreImage( imageModel: () -> Any?, modifier: Modifier = Modifier, ) { CoilImage( imageModel = imageModel, modifier = modifier, loading = { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { CircularWavyProgressIndicator() } }, failure = { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { Icon( imageVector = Icons.Default.Warning, contentDescription = null, tint = MaterialTheme.colorScheme.error, modifier = Modifier.fillMaxSize(.5f), ) } }, component = rememberImageComponent { CrossfadePlugin() }, ) } ================================================ FILE: core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/GithubStoreButton.kt ================================================ package zed.rainxch.core.presentation.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun GithubStoreButton( text: String, onClick: () -> Unit, modifier: Modifier = Modifier, icon: (@Composable () -> Unit)? = null, enabled: Boolean = true, style: GithubButtonStyle = GithubButtonStyle.Filled, ) { Button( onClick = onClick, modifier = modifier, colors = style.colors(), enabled = enabled, shapes = ButtonDefaults.shapes(), contentPadding = if (icon != null) { PaddingValues(start = 16.dp, end = 24.dp, top = 10.dp, bottom = 10.dp) } else { PaddingValues(horizontal = 24.dp, vertical = 10.dp) }, ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { icon?.invoke() Text( text = text, style = MaterialTheme.typography.labelLarge, maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis, ) } } } enum class GithubButtonStyle { Filled, Tonal, Outlined, Text, ; @Composable fun colors() = when (this) { Filled -> ButtonDefaults.buttonColors() Tonal -> ButtonDefaults.filledTonalButtonColors() Outlined -> ButtonDefaults.outlinedButtonColors() Text -> ButtonDefaults.textButtonColors() } } ================================================ FILE: core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/RepositoryCard.kt ================================================ package zed.rainxch.core.presentation.components 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.ExperimentalLayoutApi 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.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.CallSplit import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.OpenInBrowser import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.Update import androidx.compose.material.icons.outlined.Visibility import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text 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.platform.LocalUriHandler import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import zed.rainxch.core.domain.model.DiscoveryPlatform import zed.rainxch.core.presentation.model.DiscoveryRepositoryUi import zed.rainxch.core.presentation.model.GithubRepoSummaryUi import zed.rainxch.core.presentation.model.GithubUserUi import zed.rainxch.core.presentation.theme.GithubStoreTheme import zed.rainxch.core.presentation.utils.formatReleasedAt import zed.rainxch.core.presentation.utils.hasWeekNotPassed import zed.rainxch.core.presentation.utils.toIcons import zed.rainxch.githubstore.core.presentation.res.Res import zed.rainxch.githubstore.core.presentation.res.forked_repository import zed.rainxch.githubstore.core.presentation.res.home_view_details import zed.rainxch.githubstore.core.presentation.res.installed import zed.rainxch.githubstore.core.presentation.res.open_in_browser import zed.rainxch.githubstore.core.presentation.res.seen_badge import zed.rainxch.githubstore.core.presentation.res.share_repository import zed.rainxch.githubstore.core.presentation.res.update_available @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalLayoutApi::class) @Composable fun RepositoryCard( discoveryRepositoryUi: DiscoveryRepositoryUi, onClick: () -> Unit, onShareClick: () -> Unit, onDeveloperClick: (String) -> Unit, modifier: Modifier = Modifier, ) { val uriHandler = LocalUriHandler.current ExpressiveCard( onClick = onClick, modifier = modifier, ) { Box { if (discoveryRepositoryUi.isFavourite) { Icon( imageVector = Icons.Default.Favorite, contentDescription = null, tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.08f), modifier = Modifier .size(120.dp) .align(Alignment.BottomStart) .offset(x = (-32).dp, y = 32.dp), ) } if (discoveryRepositoryUi.isStarred) { Icon( imageVector = Icons.Default.Star, contentDescription = null, tint = MaterialTheme.colorScheme.secondary.copy(alpha = 0.08f), modifier = Modifier .size(120.dp) .align(Alignment.TopEnd) .offset(x = 32.dp, y = (-32).dp), ) } Column( modifier = Modifier .fillMaxWidth() .padding(16.dp), ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { Row( modifier = Modifier .clip(CircleShape) .clickable(onClick = { onDeveloperClick(discoveryRepositoryUi.repository.owner.login) }) .padding(horizontal = 4.dp, vertical = 2.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { GitHubStoreImage( imageModel = { discoveryRepositoryUi.repository.owner.avatarUrl }, modifier = Modifier .size(40.dp) .clip(CircleShape), ) Text( text = discoveryRepositoryUi.repository.owner.login, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.outline, maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis, ) } Text( text = "/", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.outline, softWrap = false, overflow = TextOverflow.Ellipsis, maxLines = 1, modifier = Modifier.weight(1f), ) } Spacer(modifier = Modifier.height(4.dp)) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Text( text = discoveryRepositoryUi.repository.name, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false), ) if (discoveryRepositoryUi.repository.isFork) { ForkBadge() } } Spacer(modifier = Modifier.height(4.dp)) discoveryRepositoryUi.repository.description?.let { Text( text = it, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyLarge, softWrap = true, ) } Spacer(Modifier.height(8.dp)) Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp), ) { Text( text = "⭐ ${discoveryRepositoryUi.repository.stargazersCount}", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis, ) Text( text = "• 🌴 ${discoveryRepositoryUi.repository.forksCount}", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis, ) discoveryRepositoryUi.repository.language?.let { Text( text = "• $it", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis, ) } } if (discoveryRepositoryUi.isInstalled || discoveryRepositoryUi.isSeen) { Spacer(Modifier.height(12.dp)) Row( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { if (discoveryRepositoryUi.isInstalled) { InstallStatusBadge( isUpdateAvailable = discoveryRepositoryUi.isUpdateAvailable, ) } if (discoveryRepositoryUi.isSeen) { SeenBadge() } } } if (discoveryRepositoryUi.repository.availablePlatforms.isNotEmpty()) { Spacer(Modifier.height(12.dp)) FlowRow( horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp), ) { discoveryRepositoryUi.repository.availablePlatforms.forEach { platform -> PlatformChip(platform = platform) } } } Spacer(Modifier.height(8.dp)) val releasedAtText = buildAnnotatedString { if (hasWeekNotPassed(discoveryRepositoryUi.repository.updatedAt)) { append("🔥 ") } append(formatReleasedAt(discoveryRepositoryUi.repository.updatedAt)) } Text( text = releasedAtText, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.outline, maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis, ) Spacer(Modifier.height(12.dp)) Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { GithubStoreButton( text = stringResource(Res.string.home_view_details), onClick = onClick, modifier = Modifier.weight(1f), ) IconButton( onClick = onShareClick, colors = IconButtonDefaults.iconButtonColors( containerColor = MaterialTheme.colorScheme.primaryContainer, contentColor = MaterialTheme.colorScheme.onPrimaryContainer, ), shapes = IconButtonDefaults.shapes(), ) { Icon( imageVector = Icons.Default.Share, contentDescription = stringResource(Res.string.share_repository), ) } IconButton( onClick = { uriHandler.openUri(discoveryRepositoryUi.repository.htmlUrl) }, colors = IconButtonDefaults.iconButtonColors( containerColor = MaterialTheme.colorScheme.primaryContainer, contentColor = MaterialTheme.colorScheme.onPrimaryContainer, ), shapes = IconButtonDefaults.shapes(), ) { Icon( imageVector = Icons.Default.OpenInBrowser, contentDescription = stringResource(Res.string.open_in_browser), ) } } } } } } @Composable fun PlatformChip( platform: DiscoveryPlatform, modifier: Modifier = Modifier, ) { Surface( modifier = modifier, shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.surfaceContainerHighest, ) { FlowRow( modifier = Modifier.padding(vertical = 4.dp, horizontal = 8.dp), horizontalArrangement = Arrangement.spacedBy(2.dp), verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically), ) { platform.toIcons().forEach { icon -> Icon( imageVector = icon, contentDescription = null, modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurface, ) } Text( text = platform.name, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, fontWeight = FontWeight.Medium, modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), ) } } } @Composable fun ForkBadge(modifier: Modifier = Modifier) { Surface( modifier = modifier, shape = RoundedCornerShape(12.dp), color = MaterialTheme.colorScheme.secondaryContainer, ) { Row( modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { Icon( imageVector = Icons.AutoMirrored.Outlined.CallSplit, contentDescription = null, modifier = Modifier.size(14.dp), tint = MaterialTheme.colorScheme.onSecondaryContainer, ) Text( text = stringResource(Res.string.forked_repository), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSecondaryContainer, fontWeight = FontWeight.SemiBold, ) } } } @Composable fun InstallStatusBadge( isUpdateAvailable: Boolean, modifier: Modifier = Modifier, ) { val backgroundColor = if (isUpdateAvailable) { MaterialTheme.colorScheme.tertiaryContainer } else { MaterialTheme.colorScheme.primaryContainer } val textColor = if (isUpdateAvailable) { MaterialTheme.colorScheme.onTertiaryContainer } else { MaterialTheme.colorScheme.onPrimaryContainer } val icon = if (isUpdateAvailable) { Icons.Default.Update } else { Icons.Default.CheckCircle } val text = if (isUpdateAvailable) { stringResource(Res.string.update_available) } else { stringResource(Res.string.installed) } Surface( modifier = modifier, shape = RoundedCornerShape(12.dp), color = backgroundColor, ) { Row( modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { Icon( imageVector = icon, contentDescription = null, modifier = Modifier.size(14.dp), tint = textColor, ) Text( text = text, style = MaterialTheme.typography.labelSmall, color = textColor, fontWeight = FontWeight.SemiBold, ) } } } @Composable fun SeenBadge(modifier: Modifier = Modifier) { Surface( modifier = modifier, shape = RoundedCornerShape(12.dp), color = MaterialTheme.colorScheme.surfaceContainerHighest, ) { Row( modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { Icon( imageVector = Icons.Outlined.Visibility, contentDescription = null, modifier = Modifier.size(14.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( text = stringResource(Res.string.seen_badge), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, fontWeight = FontWeight.SemiBold, ) } } } @Preview @Composable fun RepositoryCardPreview() { GithubStoreTheme { RepositoryCard( discoveryRepositoryUi = DiscoveryRepositoryUi( repository = GithubRepoSummaryUi( id = 0L, name = "Hello", fullName = "JIFEOJEF", owner = GithubUserUi( id = 0L, login = "Skydoves", avatarUrl = "ewfew", htmlUrl = "grgrre", ), description = "Hello wolrd Hello wolrd Hello wolrd Hello wolrd Hello wolrd", htmlUrl = "", stargazersCount = 20, forksCount = 4, language = "Kotlin", topics = null, releasesUrl = "", updatedAt = "2025-12-01T12:00:00Z", defaultBranch = "", ), isUpdateAvailable = true, isFavourite = true, isInstalled = true, isStarred = false, ), onClick = { }, onShareClick = { }, onDeveloperClick = { }, ) } } ================================================ FILE: core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/locals/LocalBottomNavigationHeight.kt ================================================ package zed.rainxch.core.presentation.locals import androidx.compose.runtime.compositionLocalOf import androidx.compose.ui.unit.Dp val LocalBottomNavigationHeight = compositionLocalOf { error("Not initialized yet") } ================================================ FILE: core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/locals/LocalBottomNavigationLiquid.kt ================================================ package zed.rainxch.core.presentation.locals import androidx.compose.runtime.compositionLocalOf import io.github.fletchmckee.liquid.LiquidState val LocalBottomNavigationLiquid = compositionLocalOf { error("State not declared") } ================================================ FILE: core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/model/DiscoveryRepositoryUi.kt ================================================ package zed.rainxch.core.presentation.model data class DiscoveryRepositoryUi( val isInstalled: Boolean, val isUpdateAvailable: Boolean, val isFavourite: Boolean, val isStarred: Boolean, val isSeen: Boolean = false, val repository: GithubRepoSummaryUi, ) ================================================ FILE: core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/model/GithubRepoSummaryUi.kt ================================================ package zed.rainxch.core.presentation.model import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import zed.rainxch.core.domain.model.DiscoveryPlatform data class GithubRepoSummaryUi( val id: Long, val name: String, val fullName: String, val owner: GithubUserUi, val description: String?, val defaultBranch: String, val htmlUrl: String, val stargazersCount: Int, val forksCount: Int, val language: String?, val topics: ImmutableList?, val releasesUrl: String, val updatedAt: String, val isFork: Boolean = false, val availablePlatforms: ImmutableList = persistentListOf(), ) ================================================ FILE: core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/model/GithubUserUi.kt ================================================ package zed.rainxch.core.presentation.model data class GithubUserUi( val id: Long, val login: String, val avatarUrl: String, val htmlUrl: String, ) ================================================ FILE: core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/theme/Color.kt ================================================ package zed.rainxch.core.presentation.theme import androidx.compose.material3.ColorScheme import androidx.compose.ui.graphics.Color val primaryLight = Color(0xFF2A638A) val onPrimaryLight = Color(0xFFFFFFFF) val primaryContainerLight = Color(0xFFCBE6FF) val onPrimaryContainerLight = Color(0xFF034B71) val secondaryLight = Color(0xFF50606F) val onSecondaryLight = Color(0xFFFFFFFF) val secondaryContainerLight = Color(0xFFD4E4F6) val onSecondaryContainerLight = Color(0xFF394856) val tertiaryLight = Color(0xFF66587B) val onTertiaryLight = Color(0xFFFFFFFF) val tertiaryContainerLight = Color(0xFFECDCFF) val onTertiaryContainerLight = Color(0xFF4E4162) val errorLight = Color(0xFFBA1A1A) val onErrorLight = Color(0xFFFFFFFF) val errorContainerLight = Color(0xFFFFDAD6) val onErrorContainerLight = Color(0xFF93000A) val backgroundLight = Color(0xFFF7F9FF) val onBackgroundLight = Color(0xFF181C20) val surfaceLight = Color(0xFFF7F9FF) val onSurfaceLight = Color(0xFF181C20) val surfaceVariantLight = Color(0xFFDEE3EA) val onSurfaceVariantLight = Color(0xFF42474D) val outlineLight = Color(0xFF72787E) val outlineVariantLight = Color(0xFFC1C7CE) val scrimLight = Color(0xFF000000) val inverseSurfaceLight = Color(0xFF2D3135) val inverseOnSurfaceLight = Color(0xFFEEF1F6) val inversePrimaryLight = Color(0xFF98CCF9) val surfaceDimLight = Color(0xFFD7DADF) val surfaceBrightLight = Color(0xFFF7F9FF) val surfaceContainerLowestLight = Color(0xFFFFFFFF) val surfaceContainerLowLight = Color(0xFFF1F4F9) val surfaceContainerLight = Color(0xFFEBEEF3) val surfaceContainerHighLight = Color(0xFFE6E8EE) val surfaceContainerHighestLight = Color(0xFFE0E3E8) val primaryDark = Color(0xFF98CCF9) val onPrimaryDark = Color(0xFF003350) val primaryContainerDark = Color(0xFF034B71) val onPrimaryContainerDark = Color(0xFFCBE6FF) val secondaryDark = Color(0xFFB8C8D9) val onSecondaryDark = Color(0xFF22323F) val secondaryContainerDark = Color(0xFF394856) val onSecondaryContainerDark = Color(0xFFD4E4F6) val tertiaryDark = Color(0xFFD1BFE7) val onTertiaryDark = Color(0xFF372B4A) val tertiaryContainerDark = Color(0xFF4E4162) val onTertiaryContainerDark = Color(0xFFECDCFF) val errorDark = Color(0xFFFFB4AB) val onErrorDark = Color(0xFF690005) val errorContainerDark = Color(0xFF93000A) val onErrorContainerDark = Color(0xFFFFDAD6) val backgroundDark = Color(0xFF101417) val onBackgroundDark = Color(0xFFE0E3E8) val surfaceDark = Color(0xFF101417) val onSurfaceDark = Color(0xFFE0E3E8) val surfaceVariantDark = Color(0xFF42474D) val onSurfaceVariantDark = Color(0xFFC1C7CE) val outlineDark = Color(0xFF8C9198) val outlineVariantDark = Color(0xFF42474D) val scrimDark = Color(0xFF000000) val inverseSurfaceDark = Color(0xFFE0E3E8) val inverseOnSurfaceDark = Color(0xFF2D3135) val inversePrimaryDark = Color(0xFF2A638A) val surfaceDimDark = Color(0xFF101417) val surfaceBrightDark = Color(0xFF363A3E) val surfaceContainerLowestDark = Color(0xFF0B0F12) val surfaceContainerLowDark = Color(0xFF181C20) val surfaceContainerDark = Color(0xFF1C2024) val surfaceContainerHighDark = Color(0xFF272A2E) val surfaceContainerHighestDark = Color(0xFF313539) fun ColorScheme.toAmoled(): ColorScheme = this.copy( background = Color.Black, surface = Color.Black, surfaceContainer = Color(0xFF0A0A0A), surfaceContainerLow = Color(0xFF050505), surfaceContainerLowest = Color.Black, surfaceContainerHigh = Color(0xFF121212), surfaceContainerHighest = Color(0xFF1A1A1A), surfaceDim = Color(0xFF0D0D0D), surfaceBright = Color(0xFF1F1F1F), surfaceVariant = Color(0xFF121212), ) ================================================ FILE: core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/theme/Theme.kt ================================================ package zed.rainxch.core.presentation.theme import androidx.compose.material3.ColorScheme import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialExpressiveTheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MotionScheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import zed.rainxch.core.domain.model.AppTheme import zed.rainxch.core.domain.model.FontTheme import zed.rainxch.core.presentation.utils.darkScheme import zed.rainxch.core.presentation.utils.lightScheme val oceanBlueLight = lightColorScheme( primary = primaryLight, onPrimary = onPrimaryLight, primaryContainer = primaryContainerLight, onPrimaryContainer = onPrimaryContainerLight, secondary = secondaryLight, onSecondary = onSecondaryLight, secondaryContainer = secondaryContainerLight, onSecondaryContainer = onSecondaryContainerLight, tertiary = tertiaryLight, onTertiary = onTertiaryLight, tertiaryContainer = tertiaryContainerLight, onTertiaryContainer = onTertiaryContainerLight, error = errorLight, onError = onErrorLight, errorContainer = errorContainerLight, onErrorContainer = onErrorContainerLight, background = backgroundLight, onBackground = onBackgroundLight, surface = surfaceLight, onSurface = onSurfaceLight, surfaceVariant = surfaceVariantLight, onSurfaceVariant = onSurfaceVariantLight, outline = outlineLight, outlineVariant = outlineVariantLight, scrim = scrimLight, inverseSurface = inverseSurfaceLight, inverseOnSurface = inverseOnSurfaceLight, inversePrimary = inversePrimaryLight, surfaceDim = surfaceDimLight, surfaceBright = surfaceBrightLight, surfaceContainerLowest = surfaceContainerLowestLight, surfaceContainerLow = surfaceContainerLowLight, surfaceContainer = surfaceContainerLight, surfaceContainerHigh = surfaceContainerHighLight, surfaceContainerHighest = surfaceContainerHighestLight, ) val oceanBlueDark = darkColorScheme( primary = primaryDark, onPrimary = onPrimaryDark, primaryContainer = primaryContainerDark, onPrimaryContainer = onPrimaryContainerDark, secondary = secondaryDark, onSecondary = onSecondaryDark, secondaryContainer = secondaryContainerDark, onSecondaryContainer = onSecondaryContainerDark, tertiary = tertiaryDark, onTertiary = onTertiaryDark, tertiaryContainer = tertiaryContainerDark, onTertiaryContainer = onTertiaryContainerDark, error = errorDark, onError = onErrorDark, errorContainer = errorContainerDark, onErrorContainer = onErrorContainerDark, background = backgroundDark, onBackground = onBackgroundDark, surface = surfaceDark, onSurface = onSurfaceDark, surfaceVariant = surfaceVariantDark, onSurfaceVariant = onSurfaceVariantDark, outline = outlineDark, outlineVariant = outlineVariantDark, scrim = scrimDark, inverseSurface = inverseSurfaceDark, inverseOnSurface = inverseOnSurfaceDark, inversePrimary = inversePrimaryDark, surfaceDim = surfaceDimDark, surfaceBright = surfaceBrightDark, surfaceContainerLowest = surfaceContainerLowestDark, surfaceContainerLow = surfaceContainerLowDark, surfaceContainer = surfaceContainerDark, surfaceContainerHigh = surfaceContainerHighDark, surfaceContainerHighest = surfaceContainerHighestDark, ) val deepPurpleLight = lightColorScheme( primary = Color(0xFF6750A4), onPrimary = Color(0xFFFFFFFF), primaryContainer = Color(0xFFE9DDFF), onPrimaryContainer = Color(0xFF22005D), secondary = Color(0xFF625B71), onSecondary = Color(0xFFFFFFFF), secondaryContainer = Color(0xFFE8DEF8), onSecondaryContainer = Color(0xFF1E192B), tertiary = Color(0xFF7E5260), onTertiary = Color(0xFFFFFFFF), tertiaryContainer = Color(0xFFFFD9E3), onTertiaryContainer = Color(0xFF31101D), error = Color(0xFFBA1A1A), onError = Color(0xFFFFFFFF), errorContainer = Color(0xFFFFDAD6), onErrorContainer = Color(0xFF410002), background = Color(0xFFFFFBFF), onBackground = Color(0xFF1C1B1E), surface = Color(0xFFFFFBFF), onSurface = Color(0xFF1C1B1E), surfaceVariant = Color(0xFFE7E0EB), onSurfaceVariant = Color(0xFF49454E), outline = Color(0xFF7A757F), outlineVariant = Color(0xFFCAC4CF), scrim = Color(0xFF000000), inverseSurface = Color(0xFF313033), inverseOnSurface = Color(0xFFF4EFF4), inversePrimary = Color(0xFFCFBCFF), surfaceDim = Color(0xFFDED8DD), surfaceBright = Color(0xFFFFFBFF), surfaceContainerLowest = Color(0xFFFFFFFF), surfaceContainerLow = Color(0xFFF8F2F7), surfaceContainer = Color(0xFFF2ECF1), surfaceContainerHigh = Color(0xFFECE6EB), surfaceContainerHighest = Color(0xFFE6E1E6), ) val deepPurpleDark = darkColorScheme( primary = Color(0xFFCFBCFF), onPrimary = Color(0xFF381E72), primaryContainer = Color(0xFF4F378A), onPrimaryContainer = Color(0xFFE9DDFF), secondary = Color(0xFFCBC2DB), onSecondary = Color(0xFF332D41), secondaryContainer = Color(0xFF4A4458), onSecondaryContainer = Color(0xFFE8DEF8), tertiary = Color(0xFFEFB8C8), onTertiary = Color(0xFF4A2532), tertiaryContainer = Color(0xFF633B48), onTertiaryContainer = Color(0xFFFFD9E3), error = Color(0xFFFFB4AB), onError = Color(0xFF690005), errorContainer = Color(0xFF93000A), onErrorContainer = Color(0xFFFFDAD6), background = Color(0xFF141316), onBackground = Color(0xFFE6E1E6), surface = Color(0xFF141316), onSurface = Color(0xFFE6E1E6), surfaceVariant = Color(0xFF49454E), onSurfaceVariant = Color(0xFFCAC4CF), outline = Color(0xFF948F99), outlineVariant = Color(0xFF49454E), scrim = Color(0xFF000000), inverseSurface = Color(0xFFE6E1E6), inverseOnSurface = Color(0xFF313033), inversePrimary = Color(0xFF6750A4), surfaceDim = Color(0xFF141316), surfaceBright = Color(0xFF3A383C), surfaceContainerLowest = Color(0xFF0F0E11), surfaceContainerLow = Color(0xFF1C1B1E), surfaceContainer = Color(0xFF201F22), surfaceContainerHigh = Color(0xFF2B292D), surfaceContainerHighest = Color(0xFF363438), ) // ============================================================================ // FOREST GREEN THEME (Trusted & Verified) // ============================================================================ val forestGreenLight = lightColorScheme( primary = Color(0xFF356859), onPrimary = Color(0xFFFFFFFF), primaryContainer = Color(0xFFB8EED9), onPrimaryContainer = Color(0xFF002019), secondary = Color(0xFF4C6359), onSecondary = Color(0xFFFFFFFF), secondaryContainer = Color(0xFFCEE9DB), onSecondaryContainer = Color(0xFF092018), tertiary = Color(0xFF3F6373), onTertiary = Color(0xFFFFFFFF), tertiaryContainer = Color(0xFFC3E8FB), onTertiaryContainer = Color(0xFF001F29), error = Color(0xFFBA1A1A), onError = Color(0xFFFFFFFF), errorContainer = Color(0xFFFFDAD6), onErrorContainer = Color(0xFF410002), background = Color(0xFFF5FBF7), onBackground = Color(0xFF171D1A), surface = Color(0xFFF5FBF7), onSurface = Color(0xFF171D1A), surfaceVariant = Color(0xFFDBE5DD), onSurfaceVariant = Color(0xFF404943), outline = Color(0xFF707973), outlineVariant = Color(0xFFBFC9C1), scrim = Color(0xFF000000), inverseSurface = Color(0xFF2C322F), inverseOnSurface = Color(0xFFEDF2ED), inversePrimary = Color(0xFF9CD1BD), surfaceDim = Color(0xFFD6DBD8), surfaceBright = Color(0xFFF5FBF7), surfaceContainerLowest = Color(0xFFFFFFFF), surfaceContainerLow = Color(0xFFF0F5F1), surfaceContainer = Color(0xFFEAEFEB), surfaceContainerHigh = Color(0xFFE4E9E6), surfaceContainerHighest = Color(0xFFDFE4E0), ) val forestGreenDark = darkColorScheme( primary = Color(0xFF9CD1BD), onPrimary = Color(0xFF00382B), primaryContainer = Color(0xFF1C4F41), onPrimaryContainer = Color(0xFFB8EED9), secondary = Color(0xFFB2CDBF), onSecondary = Color(0xFF1D352C), secondaryContainer = Color(0xFF344C42), onSecondaryContainer = Color(0xFFCEE9DB), tertiary = Color(0xFFA7CCDE), onTertiary = Color(0xFF0C3443), tertiaryContainer = Color(0xFF264B5B), onTertiaryContainer = Color(0xFFC3E8FB), error = Color(0xFFFFB4AB), onError = Color(0xFF690005), errorContainer = Color(0xFF93000A), onErrorContainer = Color(0xFFFFDAD6), background = Color(0xFF0F1512), onBackground = Color(0xFFDFE4E0), surface = Color(0xFF0F1512), onSurface = Color(0xFFDFE4E0), surfaceVariant = Color(0xFF404943), onSurfaceVariant = Color(0xFFBFC9C1), outline = Color(0xFF89938C), outlineVariant = Color(0xFF404943), scrim = Color(0xFF000000), inverseSurface = Color(0xFFDFE4E0), inverseOnSurface = Color(0xFF2C322F), inversePrimary = Color(0xFF356859), surfaceDim = Color(0xFF0F1512), surfaceBright = Color(0xFF353B37), surfaceContainerLowest = Color(0xFF0A100D), surfaceContainerLow = Color(0xFF171D1A), surfaceContainer = Color(0xFF1B211E), surfaceContainerHigh = Color(0xFF262C28), surfaceContainerHighest = Color(0xFF313733), ) // ============================================================================ // SLATE GRAY THEME (Minimal Developer) // ============================================================================ val slateGrayLight = lightColorScheme( primary = Color(0xFF535E6C), onPrimary = Color(0xFFFFFFFF), primaryContainer = Color(0xFFD7E3F3), onPrimaryContainer = Color(0xFF101C27), secondary = Color(0xFF565E6B), onSecondary = Color(0xFFFFFFFF), secondaryContainer = Color(0xFFDAE2F1), onSecondaryContainer = Color(0xFF131C26), tertiary = Color(0xFF6E5676), onTertiary = Color(0xFFFFFFFF), tertiaryContainer = Color(0xFFF7D9FF), onTertiaryContainer = Color(0xFF281430), error = Color(0xFFBA1A1A), onError = Color(0xFFFFFFFF), errorContainer = Color(0xFFFFDAD6), onErrorContainer = Color(0xFF410002), background = Color(0xFFF8F9FB), onBackground = Color(0xFF191C1E), surface = Color(0xFFF8F9FB), onSurface = Color(0xFF191C1E), surfaceVariant = Color(0xFFDFE2E9), onSurfaceVariant = Color(0xFF43474E), outline = Color(0xFF73777F), outlineVariant = Color(0xFFC3C6CD), scrim = Color(0xFF000000), inverseSurface = Color(0xFF2E3133), inverseOnSurface = Color(0xFFF0F0F3), inversePrimary = Color(0xFFB4C7D9), surfaceDim = Color(0xFFD9D9DC), surfaceBright = Color(0xFFF8F9FB), surfaceContainerLowest = Color(0xFFFFFFFF), surfaceContainerLow = Color(0xFFF3F3F6), surfaceContainer = Color(0xFFEDEDF0), surfaceContainerHigh = Color(0xFFE7E8EA), surfaceContainerHighest = Color(0xFFE2E2E5), ) val slateGrayDark = darkColorScheme( primary = Color(0xFFB4C7D9), onPrimary = Color(0xFF1F2F3D), primaryContainer = Color(0xFF394654), onPrimaryContainer = Color(0xFFD7E3F3), secondary = Color(0xFFBEC6D5), onSecondary = Color(0xFF28323B), secondaryContainer = Color(0xFF3E4753), onSecondaryContainer = Color(0xFFDAE2F1), tertiary = Color(0xFFDABDE2), onTertiary = Color(0xFF3E2946), tertiaryContainer = Color(0xFF553F5D), onTertiaryContainer = Color(0xFFF7D9FF), error = Color(0xFFFFB4AB), onError = Color(0xFF690005), errorContainer = Color(0xFF93000A), onErrorContainer = Color(0xFFFFDAD6), background = Color(0xFF111416), onBackground = Color(0xFFE2E2E5), surface = Color(0xFF111416), onSurface = Color(0xFFE2E2E5), surfaceVariant = Color(0xFF43474E), onSurfaceVariant = Color(0xFFC3C6CD), outline = Color(0xFF8D9199), outlineVariant = Color(0xFF43474E), scrim = Color(0xFF000000), inverseSurface = Color(0xFFE2E2E5), inverseOnSurface = Color(0xFF2E3133), inversePrimary = Color(0xFF535E6C), surfaceDim = Color(0xFF111416), surfaceBright = Color(0xFF37393B), surfaceContainerLowest = Color(0xFF0C0F11), surfaceContainerLow = Color(0xFF191C1E), surfaceContainer = Color(0xFF1D2022), surfaceContainerHigh = Color(0xFF282A2D), surfaceContainerHighest = Color(0xFF333538), ) // ============================================================================ // AMBER ORANGE THEME (Energetic & Warm) // ============================================================================ val amberOrangeLight = lightColorScheme( primary = Color(0xFF8B5000), onPrimary = Color(0xFFFFFFFF), primaryContainer = Color(0xFFFFDCBE), onPrimaryContainer = Color(0xFF2D1600), secondary = Color(0xFF715A48), onSecondary = Color(0xFFFFFFFF), secondaryContainer = Color(0xFFFFDCBE), onSecondaryContainer = Color(0xFF28190A), tertiary = Color(0xFF54643D), onTertiary = Color(0xFFFFFFFF), tertiaryContainer = Color(0xFFD7E9B8), onTertiaryContainer = Color(0xFF131F02), error = Color(0xFFBA1A1A), onError = Color(0xFFFFFFFF), errorContainer = Color(0xFFFFDAD6), onErrorContainer = Color(0xFF410002), background = Color(0xFFFFFBFF), onBackground = Color(0xFF201B16), surface = Color(0xFFFFFBFF), onSurface = Color(0xFF201B16), surfaceVariant = Color(0xFFF2DFD1), onSurfaceVariant = Color(0xFF51443A), outline = Color(0xFF837469), outlineVariant = Color(0xFFD5C3B6), scrim = Color(0xFF000000), inverseSurface = Color(0xFF36302A), inverseOnSurface = Color(0xFFFAEFE7), inversePrimary = Color(0xFFFFB870), surfaceDim = Color(0xFFE4D9D1), surfaceBright = Color(0xFFFFFBFF), surfaceContainerLowest = Color(0xFFFFFFFF), surfaceContainerLow = Color(0xFFFEF3EB), surfaceContainer = Color(0xFFF8EDE5), surfaceContainerHigh = Color(0xFFF2E7DF), surfaceContainerHighest = Color(0xFFECE1DA), ) val amberOrangeDark = darkColorScheme( primary = Color(0xFFFFB870), onPrimary = Color(0xFF4B2800), primaryContainer = Color(0xFF6A3C00), onPrimaryContainer = Color(0xFFFFDCBE), secondary = Color(0xFFE2C1A3), onSecondary = Color(0xFF402D1D), secondaryContainer = Color(0xFF584332), onSecondaryContainer = Color(0xFFFFDCBE), tertiary = Color(0xFFBBCD9E), onTertiary = Color(0xFF273514), tertiaryContainer = Color(0xFF3D4C28), onTertiaryContainer = Color(0xFFD7E9B8), error = Color(0xFFFFB4AB), onError = Color(0xFF690005), errorContainer = Color(0xFF93000A), onErrorContainer = Color(0xFFFFDAD6), background = Color(0xFF18130E), onBackground = Color(0xFFECE1DA), surface = Color(0xFF18130E), onSurface = Color(0xFFECE1DA), surfaceVariant = Color(0xFF51443A), onSurfaceVariant = Color(0xFFD5C3B6), outline = Color(0xFF9D8E82), outlineVariant = Color(0xFF51443A), scrim = Color(0xFF000000), inverseSurface = Color(0xFFECE1DA), inverseOnSurface = Color(0xFF36302A), inversePrimary = Color(0xFF8B5000), surfaceDim = Color(0xFF18130E), surfaceBright = Color(0xFF3F3933), surfaceContainerLowest = Color(0xFF120E09), surfaceContainerLow = Color(0xFF201B16), surfaceContainer = Color(0xFF241F1A), surfaceContainerHigh = Color(0xFF2F2A24), surfaceContainerHighest = Color(0xFF3A342E), ) @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun GithubStoreTheme( isDarkTheme: Boolean = false, appTheme: AppTheme = AppTheme.OCEAN, fontTheme: FontTheme = FontTheme.CUSTOM, isAmoledTheme: Boolean = false, content: @Composable () -> Unit, ) { val baseColorScheme = when { appTheme == AppTheme.DYNAMIC -> { getDynamicColorScheme(isDarkTheme) ?: run { if (isDarkTheme) AppTheme.OCEAN.darkScheme else AppTheme.OCEAN.lightScheme } } isDarkTheme -> { appTheme.darkScheme!! } else -> { appTheme.lightScheme!! } } val colorScheme = if (isDarkTheme && isAmoledTheme) { baseColorScheme?.toAmoled() } else { baseColorScheme } MaterialExpressiveTheme( colorScheme = colorScheme, typography = getAppTypography(fontTheme), motionScheme = MotionScheme.expressive(), shapes = MaterialTheme.shapes, content = content, ) } expect fun isDynamicColorAvailable(): Boolean @Composable expect fun getDynamicColorScheme(darkTheme: Boolean): ColorScheme? ================================================ FILE: core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/theme/Type.kt ================================================ package zed.rainxch.core.presentation.theme import androidx.compose.material3.Typography import androidx.compose.runtime.Composable import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import org.jetbrains.compose.resources.Font import zed.rainxch.core.domain.model.FontTheme import zed.rainxch.githubstore.core.presentation.res.* val jetbrainsMonoFontFamily @Composable get() = FontFamily( Font(Res.font.jetbrains_mono_light, FontWeight.Light), Font(Res.font.jetbrains_mono_regular, FontWeight.Normal), Font(Res.font.jetbrains_mono_medium, FontWeight.Medium), Font(Res.font.jetbrains_mono_semi_bold, FontWeight.SemiBold), Font(Res.font.jetbrains_mono_bold, FontWeight.Bold), ) val interFontFamily @Composable get() = FontFamily( Font(Res.font.inter_light, FontWeight.Light), Font(Res.font.inter_regular, FontWeight.Normal), Font(Res.font.inter_medium, FontWeight.Medium), Font(Res.font.inter_semi_bold, FontWeight.SemiBold), Font(Res.font.inter_bold, FontWeight.Bold), Font(Res.font.inter_black, FontWeight.Black), ) val baseline = Typography() @Composable fun getAppTypography(fontTheme: FontTheme = FontTheme.CUSTOM): Typography = when (fontTheme) { FontTheme.SYSTEM -> { baseline } FontTheme.CUSTOM -> { Typography( displayLarge = baseline.displayLarge.copy(fontFamily = jetbrainsMonoFontFamily), displayMedium = baseline.displayMedium.copy(fontFamily = jetbrainsMonoFontFamily), displaySmall = baseline.displaySmall.copy(fontFamily = jetbrainsMonoFontFamily), headlineLarge = baseline.headlineLarge.copy(fontFamily = jetbrainsMonoFontFamily), headlineMedium = baseline.headlineMedium.copy(fontFamily = jetbrainsMonoFontFamily), headlineSmall = baseline.headlineSmall.copy(fontFamily = jetbrainsMonoFontFamily), titleLarge = baseline.titleLarge.copy(fontFamily = jetbrainsMonoFontFamily), titleMedium = baseline.titleMedium.copy(fontFamily = jetbrainsMonoFontFamily), titleSmall = baseline.titleSmall.copy(fontFamily = jetbrainsMonoFontFamily), bodyLarge = baseline.bodyLarge.copy(fontFamily = interFontFamily), bodyMedium = baseline.bodyMedium.copy(fontFamily = interFontFamily), bodySmall = baseline.bodySmall.copy(fontFamily = interFontFamily), labelLarge = baseline.labelLarge.copy(fontFamily = interFontFamily), labelMedium = baseline.labelMedium.copy(fontFamily = interFontFamily), labelSmall = baseline.labelSmall.copy(fontFamily = interFontFamily), ) } } ================================================ FILE: core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/AppThemeUtil.kt ================================================ package zed.rainxch.core.presentation.utils import androidx.compose.material3.ColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import org.jetbrains.compose.resources.stringResource import zed.rainxch.core.domain.model.AppTheme import zed.rainxch.core.domain.model.AppTheme.AMBER import zed.rainxch.core.domain.model.AppTheme.DYNAMIC import zed.rainxch.core.domain.model.AppTheme.FOREST import zed.rainxch.core.domain.model.AppTheme.OCEAN import zed.rainxch.core.domain.model.AppTheme.PURPLE import zed.rainxch.core.domain.model.AppTheme.SLATE import zed.rainxch.core.presentation.theme.amberOrangeDark import zed.rainxch.core.presentation.theme.amberOrangeLight import zed.rainxch.core.presentation.theme.deepPurpleDark import zed.rainxch.core.presentation.theme.deepPurpleLight import zed.rainxch.core.presentation.theme.forestGreenDark import zed.rainxch.core.presentation.theme.forestGreenLight import zed.rainxch.core.presentation.theme.oceanBlueDark import zed.rainxch.core.presentation.theme.oceanBlueLight import zed.rainxch.core.presentation.theme.slateGrayDark import zed.rainxch.core.presentation.theme.slateGrayLight import zed.rainxch.githubstore.core.presentation.res.* val AppTheme.lightScheme: ColorScheme? get() = when (this) { DYNAMIC -> null OCEAN -> oceanBlueLight PURPLE -> deepPurpleLight FOREST -> forestGreenLight SLATE -> slateGrayLight AMBER -> amberOrangeLight } val AppTheme.darkScheme: ColorScheme? get() = when (this) { DYNAMIC -> null OCEAN -> oceanBlueDark PURPLE -> deepPurpleDark FOREST -> forestGreenDark SLATE -> slateGrayDark AMBER -> amberOrangeDark } val AppTheme.primaryColor: Color? get() = when (this) { DYNAMIC -> null OCEAN -> Color(0xFF2A638A) PURPLE -> Color(0xFF6750A4) FOREST -> Color(0xFF356859) SLATE -> Color(0xFF535E6C) AMBER -> Color(0xFF8B5000) } val AppTheme.displayName: String @Composable get() = stringResource( when (this) { DYNAMIC -> Res.string.theme_dynamic OCEAN -> Res.string.theme_ocean PURPLE -> Res.string.theme_purple FOREST -> Res.string.theme_forest SLATE -> Res.string.theme_slate AMBER -> Res.string.theme_amber }, ) ================================================ FILE: core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/ApplyAndroidSystemBars.kt ================================================ package zed.rainxch.core.presentation.utils import androidx.compose.runtime.Composable @Composable expect fun ApplyAndroidSystemBars(isDarkTheme: Boolean?) ================================================ FILE: core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/CountFormatter.kt ================================================ package zed.rainxch.core.presentation.utils import androidx.compose.runtime.Composable import org.jetbrains.compose.resources.stringResource import zed.rainxch.githubstore.core.presentation.res.* @Composable fun formatCount(count: Int): String = when { count >= 1_000_000 -> stringResource(Res.string.count_millions, count / 1_000_000) count >= 1000 -> stringResource(Res.string.count_thousands, count / 1000) else -> count.toString() } ================================================ FILE: core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/DiscoveryPlatformUiResources.kt ================================================ package zed.rainxch.core.presentation.utils import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.vector.ImageVector import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.vectorResource import zed.rainxch.core.domain.model.DiscoveryPlatform import zed.rainxch.githubstore.core.presentation.res.* @Composable fun DiscoveryPlatform.toIcons(): List = when (this) { DiscoveryPlatform.All -> { listOf( vectorResource(Res.drawable.ic_platform_android), vectorResource(Res.drawable.ic_platform_linux), vectorResource(Res.drawable.ic_platform_macos), vectorResource(Res.drawable.ic_platform_windows), ) } DiscoveryPlatform.Android -> { listOf(vectorResource(Res.drawable.ic_platform_android)) } DiscoveryPlatform.Macos -> { listOf(vectorResource(Res.drawable.ic_platform_macos)) } DiscoveryPlatform.Windows -> { listOf(vectorResource(Res.drawable.ic_platform_windows)) } DiscoveryPlatform.Linux -> { listOf(vectorResource(Res.drawable.ic_platform_linux)) } } @Composable fun DiscoveryPlatform.toLabel(): String = when (this) { DiscoveryPlatform.All -> { stringResource(Res.string.category_all) } DiscoveryPlatform.Android -> { "Android" } DiscoveryPlatform.Macos -> { "macOS" } DiscoveryPlatform.Windows -> { "Windows" } DiscoveryPlatform.Linux -> { "Linux" } } ================================================ FILE: core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/GithubRepoSummaryMappers.kt ================================================ package zed.rainxch.core.presentation.utils import kotlinx.collections.immutable.toImmutableList import zed.rainxch.core.domain.model.GithubRepoSummary import zed.rainxch.core.presentation.model.GithubRepoSummaryUi fun GithubRepoSummary.toUi(): GithubRepoSummaryUi { return GithubRepoSummaryUi( id = id, name = name, fullName = fullName, owner = owner.toUi(), description = description, defaultBranch = defaultBranch, htmlUrl = htmlUrl, stargazersCount = stargazersCount, forksCount = forksCount, language = language, topics = topics?.toImmutableList(), releasesUrl = releasesUrl, updatedAt = updatedAt, isFork = isFork, availablePlatforms = availablePlatforms.toImmutableList() ) } ================================================ FILE: core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/GithubUserMappers.kt ================================================ package zed.rainxch.core.presentation.utils import zed.rainxch.core.domain.model.GithubUser import zed.rainxch.core.presentation.model.GithubUserUi fun GithubUser.toUi(): GithubUserUi { return GithubUserUi( id = id, login = login, avatarUrl = avatarUrl, htmlUrl = htmlUrl ) } ================================================ FILE: core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/ObserveAsEvents.kt ================================================ package zed.rainxch.core.presentation.utils import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.repeatOnLifecycle import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.withContext @Composable fun ObserveAsEvents( flow: Flow, key1: Any? = null, key2: Any? = null, onEvent: (T) -> Unit, ) { val lifecycleOwner = LocalLifecycleOwner.current LaunchedEffect(lifecycleOwner, key1, key2) { lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { withContext(Dispatchers.Main.immediate) { flow.collect(onEvent) } } } } ================================================ FILE: core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/TimeFormatters.kt ================================================ package zed.rainxch.core.presentation.utils import androidx.compose.runtime.Composable import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.stringResource import zed.rainxch.githubstore.core.presentation.res.* import kotlin.time.Clock import kotlin.time.Duration import kotlin.time.Duration.Companion.days import kotlin.time.ExperimentalTime import kotlin.time.Instant @OptIn(ExperimentalTime::class) fun hasWeekNotPassed(isoInstant: String): Boolean { val updated = try { Instant.parse(isoInstant) } catch (_: IllegalArgumentException) { return false } val now = Clock.System.now() val diff = now - updated return diff < 7.days } @OptIn(ExperimentalTime::class) @Composable fun formatReleasedAt(isoInstant: String): String { val updated = Instant.parse(isoInstant) val now = Instant.fromEpochMilliseconds(Clock.System.now().toEpochMilliseconds()) val diff: Duration = now - updated val hoursDiff = diff.inWholeHours val daysDiff = diff.inWholeDays return when { hoursDiff < 1 -> { stringResource(Res.string.released_just_now) } hoursDiff < 24 -> { stringResource(Res.string.released_hours_ago, hoursDiff) } daysDiff == 1L -> { stringResource(Res.string.released_yesterday) } daysDiff < 7 -> { stringResource(Res.string.released_days_ago, daysDiff) } else -> { val date = updated.toLocalDateTime(TimeZone.currentSystemDefault()).date stringResource(Res.string.released_on_date, date.toString()) } } } @OptIn(ExperimentalTime::class) suspend fun formatAddedAt(epochMillis: Long): String { val updated = Instant.fromEpochMilliseconds(epochMillis) val now = Clock.System.now() val diff: Duration = now - updated val hoursDiff = diff.inWholeHours val daysDiff = diff.inWholeDays return when { hoursDiff < 1 -> { getString(Res.string.added_just_now) } hoursDiff < 24 -> { getString(Res.string.added_hours_ago, hoursDiff) } daysDiff == 1L -> { getString(Res.string.added_yesterday) } daysDiff < 7 -> { getString(Res.string.added_days_ago, daysDiff) } else -> { val date = updated .toLocalDateTime(TimeZone.currentSystemDefault()) .date getString(Res.string.added_on_date, date.toString()) } } } ================================================ FILE: core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/isLiquidFrostAvailable.kt ================================================ package zed.rainxch.core.presentation.utils expect fun isLiquidFrostAvailable(): Boolean ================================================ FILE: core/presentation/src/jvmMain/kotlin/zed/rainxch/core/presentation/theme/Theme.jvm.kt ================================================ package zed.rainxch.core.presentation.theme import androidx.compose.material3.ColorScheme import androidx.compose.runtime.Composable actual fun isDynamicColorAvailable(): Boolean = false @Composable actual fun getDynamicColorScheme(darkTheme: Boolean): ColorScheme? = null ================================================ FILE: core/presentation/src/jvmMain/kotlin/zed/rainxch/core/presentation/utils/ApplyAndroidSystemBars.jvm.kt ================================================ package zed.rainxch.core.presentation.utils import androidx.compose.runtime.Composable @Composable actual fun ApplyAndroidSystemBars(isDarkTheme: Boolean?) { // No-op } ================================================ FILE: core/presentation/src/jvmMain/kotlin/zed/rainxch/core/presentation/utils/isLiquidFrostAvailable.jvm.kt ================================================ package zed.rainxch.core.presentation.utils actual fun isLiquidFrostAvailable(): Boolean = true ================================================ FILE: docs/README-BN.md ================================================

# GitHub Store

API Kotlin Compose Multiplatform material





OpenHub-Store%2FGitHub-Store | Trendshift Featured|HelloGitHub

# 🗺️ প্রকল্পের সংক্ষিপ্ত বিবরণ GitHub Store হলো GitHub রিলিজের জন্য একটি ক্রস-প্ল্যাটফর্ম অ্যাপ স্টোর, যা ওপেন-সোর্স সফটওয়্যার আবিষ্কার ও ইনস্টল করাকে সহজ করার জন্য তৈরি। এটি স্বয়ংক্রিয়ভাবে ইনস্টলযোগ্য বাইনারি (APK, EXE, DMG, AppImage, DEB, RPM) শনাক্ত করে, এক-ক্লিকে ইনস্টলেশন অফার করে, আপডেট ট্র্যাক করে এবং একটি পরিষ্কার অ্যাপ-স্টোর স্টাইলের ইন্টারফেসে রিপোজিটরির তথ্য উপস্থাপন করে। Android ও Desktop প্ল্যাটফর্মের জন্য Kotlin Multiplatform ও Compose Multiplatform দিয়ে তৈরি।
> [!CAUTION] > ফ্রি এবং ওপেন-সোর্স Android হুমকির মুখে। Google Android-কে একটি বন্ধ প্ল্যাটফর্মে পরিণত করবে, আপনার পছন্দের অ্যাপ ইনস্টল করার মৌলিক স্বাধীনতা সীমিত করবে। আপনার মতামত জানান – [keepandroidopen.org](https://keepandroidopen.org/)।

# 📔 উইকি ও রিসোর্স প্রায়শই জিজ্ঞাসিত প্রশ্ন ও দরকারী তথ্যের জন্য GitHub Store [উইকি](https://github.com/OpenHub-Store/GitHub-Store/wiki) দেখুন 🌐 **ওয়েবসাইট:** [github-store.org](https://github-store.org) 💬 **Discord:** [কমিউনিটিতে যোগ দিন](https://discord.gg/x9Cvh2Z9qS) 📜 **গোপনীয়তা নীতি:** [github-store.org/privacy-policy](https://github-store.org/privacy-policy/)
---
### 📋 আইনি নোটিশ GitHub Store একটি স্বাধীন, ওপেন-সোর্স প্রকল্প যা GitHub, Inc.-এর সাথে সম্পর্কিত নয়। নামটি অ্যাপের কার্যকারিতা বর্ণনা করে (GitHub রিলিজ আবিষ্কার করা) এবং ট্রেডমার্ক মালিকানা বোঝায় না। GitHub® হলো GitHub, Inc.-এর একটি নিবন্ধিত ট্রেডমার্ক।
---

# 🔃 ডাউনলোড

Get it on Obtainium Get it on GitHub Store

> [!IMPORTANT] > **macOS ব্যবহারকারীরা:** আপনি একটি সতর্কতা দেখতে পারেন যে Apple GitHub Store যাচাই করতে পারছে না। এটি ঘটে কারণ অ্যাপটি App Store-এর বাইরে বিতরণ করা হয় এবং এখনো নোটারাইজড নয়। System Settings → Privacy & Security → Open Anyway-এর মাধ্যমে এটি অনুমতি দিন। ---

# 🏆 যেখানে প্রদর্শিত হয়েছে

Featured by HowToMen
HowToMen: ২০২৬ সালের সেরা ২০টি Android অ্যাপ | Google Play Store-এর চেয়ে ভালো ১২টি অ্যাপ স্টোর
HelloGitHub: বৈশিষ্ট্যযুক্ত প্রকল্প

--- ## 🚀 বৈশিষ্ট্যসমূহ - **স্মার্ট আবিষ্কার** - সময়-ভিত্তিক ফিল্টার সহ "Trending", "Hot Release" এবং "Most Popular" প্রকল্পের জন্য হোম বিভাগ। - শুধুমাত্র বৈধ ইনস্টলযোগ্য অ্যাসেট সহ রিপোজিটরি দেখানো হয়। - Android/desktop ব্যবহারকারীরা প্রথমে প্রাসঙ্গিক অ্যাপ দেখতে পান এমন প্ল্যাটফর্ম-সচেতন টপিক স্কোরিং। - উন্নত প্রাসঙ্গিকতা র‍্যাঙ্কিং ও পারফরম্যান্স সহ পুনর্নির্মিত সার্চ। - **রিলিজ ব্রাউজার ও ইনস্টলেশন** - শুধু সর্বশেষ নয়, যেকোনো রিলিজ থেকে ব্রাউজ ও ইনস্টল করতে রিলিজ পিকার। - প্রতিটি রিপোজিটরির সব রিলিজ আনে। - একক "সর্বশেষ ইনস্টল করুন" অ্যাকশন, এবং সব উপলব্ধ রিলিজ ও ইনস্টলারের বিস্তারযোগ্য তালিকা। - স্বয়ংক্রিয় সামঞ্জস্যতা যাচাই সহ ম্যানুয়াল ইনস্টল বিকল্প। - **সমৃদ্ধ বিস্তারিত স্ক্রিন** - অ্যাপের নাম, সংস্করণ ও শেয়ার অ্যাকশন। - স্টার, ফর্ক, খোলা ইস্যু। - রেন্ডার করা README কন্টেন্ট ("এই অ্যাপ সম্পর্কে")। - যেকোনো নির্বাচিত রিলিজের জন্য Markdown ফরম্যাটিং সহ রিলিজ নোট। - প্ল্যাটফর্ম লেবেল ও ফাইলের আকার সহ ইনস্টলারের তালিকা। - ডিপ লিংকিং সাপোর্ট — URL-এর মাধ্যমে সরাসরি রিপোজিটরির বিবরণ খুলুন। - একজন ডেভেলপারের রিপোজিটরি ও কার্যকলাপ অন্বেষণ করতে ডেভেলপার প্রোফাইল স্ক্রিন। - **অ্যাপ ম্যানেজমেন্ট** - GitHub Store থেকে সরাসরি ইনস্টল করা অ্যাপ খুলুন, আনইনস্টল করুন এবং ডাউনগ্রেড করুন। - Android: APK আর্কিটেকচার ম্যাচিং (armv7/armv8), প্যাকেজ মনিটরিং ও আপডেট ট্র্যাকিং। - Desktop (Windows/macOS/Linux): ব্যবহারকারীর Downloads ফোল্ডারে ইনস্টলার ডাউনলোড করে এবং ডিফল্ট হ্যান্ডলার দিয়ে খোলে। - **স্টার করা রিপোজিটরি** - অ্যাপের মধ্যে থেকে আপনার স্টার করা GitHub রিপোজিটরি সংরক্ষণ ও ব্রাউজ করুন। - **নেটওয়ার্ক ও পারফরম্যান্স** - কনফিগারযোগ্য নেটওয়ার্ক রাউটিংয়ের জন্য ডায়নামিক প্রক্সি সাপোর্ট। - দ্রুত লোডিং ও কমিয়ে API ব্যবহারের জন্য উন্নত ক্যাশিং সিস্টেম। --- ## 🔍 আমার অ্যাপ GitHub Store-এ কীভাবে দেখা যাবে? GitHub Store কোনো ব্যক্তিগত ইন্ডেক্সিং বা ম্যানুয়াল কিউরেশন নিয়ম ব্যবহার করে না। নিচের শর্তগুলো পূরণ করলে আপনার প্রকল্প স্বয়ংক্রিয়ভাবে প্রদর্শিত হতে পারে: 1. **GitHub-এ পাবলিক রিপোজিটরি** - ভিজিবিলিটি অবশ্যই `public` হতে হবে। 2. **সর্বশেষ রিলিজে ইনস্টলযোগ্য অ্যাসেট** - সর্বশেষ রিলিজে সমর্থিত এক্সটেনশন সহ অন্তত একটি অ্যাসেট ফাইল থাকতে হবে: - Android: `.apk` - Windows: `.exe`, `.msi` - macOS: `.dmg`, `.pkg` - Linux: `.deb`, `.rpm`, `.AppImage` - GitHub Store স্বয়ংক্রিয়ভাবে তৈরি সোর্স আর্টিফ্যাক্ট (`Source code (zip)` / `Source code (tar.gz)`) উপেক্ষা করে। 3. **সার্চ / টপিক দ্বারা আবিষ্কারযোগ্য** - পাবলিক GitHub Search API-এর মাধ্যমে রিপোজিটরি আনা হয়। - টপিক, ভাষা ও বিবরণ র‍্যাঙ্কিংয়ে সাহায্য করে: - Android অ্যাপ: `android`, `mobile`, `apk`-এর মতো টপিক। - Desktop অ্যাপ: `desktop`, `windows`, `linux`, `macos`, `compose-desktop`, `electron`-এর মতো টপিক। - অন্তত কয়েকটি স্টার থাকলে Trending/Hot Release/Most Popular বিভাগে দেখানোর সম্ভাবনা বেশি। আপনার রিপোজিটরি এই শর্তগুলো পূরণ করলে, GitHub Store সার্চের মাধ্যমে এটি খুঁজে পেতে এবং স্বয়ংক্রিয়ভাবে দেখাতে পারে — কোনো ম্যানুয়াল সাবমিশনের প্রয়োজন নেই। --- ## ✅ সুবিধা / কেন GitHub Store ব্যবহার করবেন? - **GitHub রিলিজে আর খোঁজাখুঁজি নয়** শুধুমাত্র সেই রিপোজিটরিগুলো দেখুন যেগুলো আসলে আপনার প্ল্যাটফর্মের জন্য বাইনারি শিপ করে। - **আপনি কী ইনস্টল করেছেন তা জানে** GitHub Store (Android) দিয়ে ইনস্টল করা অ্যাপ ট্র্যাক করে এবং নতুন রিলিজ পাওয়া গেলে হাইলাইট করে, যাতে আপনাকে আবার GitHub-এ খুঁজতে না হয়। - **সবসময় আপ টু ডেট** ইনস্টলেশন ডিফল্টভাবে সর্বশেষ প্রকাশিত রিলিজ ব্যবহার করে, রিলিজ পিকারের মাধ্যমে যেকোনো পূর্ববর্তী রিলিজ থেকে ব্রাউজ ও ইনস্টল করার বিকল্পসহ। - **ওপেন সোর্স ও বিস্তারযোগ্য** নেটওয়ার্কিং, ডোমেইন লজিক ও UI-এর মধ্যে স্পষ্ট বিভাজন সহ KMP-তে লেখা — ফর্ক, বিস্তার বা অভিযোজন করা সহজ। --- ## 🔐 GitHub Store APK সাইনিং সার্টিফিকেট সব অফিসিয়াল GitHub Store রিলিজ নিচের সার্টিফিকেট ফিঙ্গারপ্রিন্ট দিয়ে স্বাক্ষরিত: SHA-256: `B7:F2:8E:19:8E:48:C1:93:B0:38:C6:5D:92:DD:F7:BC:07:7B:0D:B5:9E:BC:9B:25:0A:6D:AC:48:C1:18:03:CA` --- ## 🔑 GitHub OAuth কনফিগারেশন **সংক্ষেপে** 1. একটি GitHub OAuth App তৈরি করুন 2. **Client ID** কপি করুন 3. `local.properties`-এ রাখুন
সম্পূর্ণ সেটআপ গাইড দেখুন
### ১ - একটি GitHub OAuth App তৈরি করুন এখানে যান: **GitHub → Settings → Developer settings → OAuth Apps → New OAuth App** | ফিল্ড | মান | | ------------------------------ | ------------------------------------------- | | **Application name** | যা খুশি (যেমন *GitHub Store Dev*) | | **Homepage URL** | `https://github.com/username/repo_name` | | **Authorization callback URL** | `githubstore://callback` | তারপর **Create application**-এ ক্লিক করুন। ### ২ - আপনার Client ID কপি করুন অ্যাপ তৈরির পর GitHub দেখাবে: - **Client ID** ← এটাই আপনার দরকার - **Client Secret** ← ❗ এই প্রকল্পের জন্য প্রয়োজন নেই ### ৩ - আপনার প্রকল্পে যোগ করুন আপনার প্রকল্পের `local.properties` ফাইল (প্রকল্পের রুট) খুলুন এবং যোগ করুন: ```properties GITHUB_CLIENT_ID=YOUR_CLIENT_ID_HERE ``` ### ৪ - সিঙ্ক ও রান করুন প্রকল্পটি সিঙ্ক করুন এবং অ্যাপ চালু করুন। এখন আপনি GitHub দিয়ে সাইন ইন করতে পারবেন। ### ❗ গুরুত্বপূর্ণ নোট - `local.properties` **Git-এ কমিট হয় না**, তাই আপনার Client ID স্থানীয় থাকে। - এই প্রকল্পে শুধু **Client ID** দরকার (Client Secret নয়)। - প্রতিটি ডেভেলপারের উচিত ডেভেলপমেন্টের জন্য নিজস্ব OAuth অ্যাপ তৈরি করা।
--- ## ☕ প্রকল্পটি সাপোর্ট করুন GitHub Store একজন হাই স্কুল ছাত্র দ্বারা তৈরি ও রক্ষণাবেক্ষণ করা হয়। আপনার সাপোর্ট তাকে সাহায্য করে: ✅ **অ্যাপকে বাগ-মুক্ত রাখতে** — ইস্যুতে সাড়া দিতে এবং দ্রুত ফিক্স পাঠাতে ✅ **কমিউনিটির অনুরোধ করা ফিচার যোগ করতে** — ব্যবহারকারীরা আসলে যা চান তা বাস্তবায়ন করতে ### 💖 সাপোর্টের উপায় Buy Me a Coffee GitHub Sponsors **এখন স্পনসর করতে পারছেন না?** ঠিক আছে! আপনি এভাবেও সাহায্য করতে পারেন: - ⭐ **এই রিপোতে স্টার দিন** — অন্যদের GitHub Store আবিষ্কার করতে সাহায্য করে - 🐛 **বাগ রিপোর্ট করুন** — সবার জন্য অ্যাপটি আরও ভালো করে - 📢 **বন্ধুদের সাথে শেয়ার করুন** — অন্য ডেভেলপার ও পরিচিতদের মধ্যে ছড়িয়ে দিন! - 💬 **আমাদের [Discord](https://discord.gg/x9Cvh2Z9qS)-এ যোগ দিন** — আপনার মতামত রোডম্যাপ তৈরি করে প্রতিটি সাপোর্ট — আর্থিক হোক বা না হোক — অনেক মূল্যবান এবং এই প্রকল্পকে বাঁচিয়ে রাখে। ধন্যবাদ! --- ## ⚠️ দায়বদ্ধতার অস্বীকৃতি GitHub Store শুধুমাত্র GitHub-এ তৃতীয় পক্ষের ডেভেলপারদের দ্বারা ইতোমধ্যে প্রকাশিত রিলিজ অ্যাসেট আবিষ্কার ও ডাউনলোড করতে সাহায্য করে। সেই ডাউনলোডগুলোর বিষয়বস্তু, নিরাপত্তা ও আচরণ সম্পূর্ণরূপে তাদের নিজস্ব লেখক ও বিতরণকারীদের দায়িত্ব, এই প্রকল্পের নয়। GitHub Store ব্যবহার করে, আপনি বুঝতে ও সম্মত হন যে আপনি নিজের ঝুঁকিতে যেকোনো ডাউনলোড করা সফটওয়্যার ইনস্টল ও চালু করছেন। এই প্রকল্প কোনো ইনস্টলার নিরাপদ, ম্যালওয়্যার-মুক্ত বা কোনো নির্দিষ্ট উদ্দেশ্যের জন্য উপযুক্ত কিনা তা পর্যালোচনা, যাচাই বা নিশ্চিত করে না। --- ## স্টার ইতিহাস Star History Chart ![Alt](https://repobeats.axiom.co/api/embed/20367dca127572e9c47db33850979d78df2c6a8b.svg "Repobeats analytics image") ## 📄 লাইসেন্স GitHub Store **Apache License, Version 2.0**-এর অধীনে প্রকাশিত হবে। ``` Copyright 2025 rainxchzed Licensed under the Apache License, Version 2.0 (the "License"); you may not use this project 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: docs/README-ES.md ================================================

# GitHub Store

API Kotlin Compose Multiplatform material





OpenHub-Store%2FGitHub-Store | Trendshift Featured|HelloGitHub

# 🗺️ Descripcion General del Proyecto GitHub Store es una tienda de aplicaciones multiplataforma para releases de GitHub, disenada para simplificar el descubrimiento e instalacion de software de codigo abierto. Detecta automaticamente binarios instalables (APK, EXE, DMG, AppImage, DEB, RPM), ofrece instalacion con un solo clic, rastrea actualizaciones y presenta la informacion de los repositorios en una interfaz limpia al estilo de una tienda de aplicaciones. Construida con Kotlin Multiplatform y Compose Multiplatform para plataformas Android y Desktop.
> [!CAUTION] > Android libre y de codigo abierto esta bajo amenaza. Google convertira Android en una plataforma cerrada, restringiendo tu libertad esencial de instalar las aplicaciones que elijas. Haz que tu voz se escuche – [keepandroidopen.org](https://keepandroidopen.org/).

# 📔 Wiki y Recursos Consulta la [Wiki](https://github.com/OpenHub-Store/GitHub-Store/wiki) de GitHub Store para preguntas frecuentes e informacion util 🌐 **Sitio web:** [github-store.org](https://github-store.org) 💬 **Discord:** [Unete a la comunidad](https://discord.gg/x9Cvh2Z9qS) 📜 **Politica de privacidad:** [github-store.org/privacy-policy](https://github-store.org/privacy-policy/)
---
### 📋 Aviso Legal GitHub Store es un proyecto independiente de codigo abierto, no afiliado a GitHub, Inc. El nombre describe la funcionalidad de la aplicacion (descubrir releases de GitHub) y no implica propiedad de marca registrada. GitHub® es una marca registrada de GitHub, Inc.
---

# 🔃 Descarga

Get it on Obtainium Get it on GitHub Store

> [!IMPORTANT] > **Usuarios de macOS:** Es posible que veas una advertencia indicando que Apple no puede verificar GitHub Store. Esto ocurre porque la aplicacion se distribuye fuera del App Store y aun no esta notarizada. Permitela a traves de Ajustes del Sistema → Privacidad y Seguridad → Abrir de todos modos. ---

# 🏆 Destacado en

Destacado por HowToMen
HowToMen: Top 20 Mejores Apps para Android 2026 | Top 12 Tiendas de Apps Mejores que Google Play Store
HelloGitHub: Proyecto Destacado

--- ## 🚀 Funcionalidades - **Descubrimiento inteligente** - Secciones de inicio para proyectos "Trending", "Hot Release" y "Most Popular" con filtros basados en tiempo. - Solo se muestran repositorios con archivos instalables validos. - Puntuacion de temas consciente de la plataforma para que los usuarios de Android/escritorio vean las apps relevantes primero. - Busqueda renovada con mejor clasificacion por relevancia y rendimiento. - **Explorador de releases e instalaciones** - Selector de releases para explorar e instalar desde cualquier release, no solo la mas reciente. - Obtiene todas las releases de cada repositorio. - Accion unica "Instalar ultima version", mas una lista desplegable de todas las releases disponibles y sus instaladores. - Opcion de instalacion manual con comprobaciones de compatibilidad automaticas. - **Pantalla de detalles enriquecida** - Nombre de la app, version y accion de compartir. - Estrellas, forks, issues abiertos. - Contenido del README renderizado ("Acerca de esta app"). - Notas de la release con formato de Markdown para cualquier release seleccionada. - Lista de instaladores con etiquetas de plataforma y tamanos de archivo. - Soporte de enlaces profundos — abre los detalles del repositorio directamente mediante URL. - Pantalla de perfil del desarrollador para explorar los repositorios y la actividad de un desarrollador. - **Gestion de aplicaciones** - Abre, desinstala y degrada aplicaciones instaladas directamente desde GitHub Store. - Android: coincidencia de arquitectura APK (armv7/armv8), monitoreo de paquetes y rastreo de actualizaciones. - Escritorio (Windows/macOS/Linux): descarga los instaladores en la carpeta de Descargas del usuario y los abre con el manejador predeterminado. - **Repositorios destacados** - Guarda y explora tus repositorios destacados de GitHub desde la aplicacion. - **Red y rendimiento** - Soporte de proxy dinamico para enrutamiento de red configurable. - Sistema de cache mejorado para una carga mas rapida y menor uso de la API. --- ## 🔍 ¿Como aparece mi aplicacion en GitHub Store? GitHub Store no utiliza ningun tipo de indexacion privada ni reglas de curacion manual. Tu proyecto puede aparecer automaticamente si cumple estas condiciones: 1. **Repositorio publico en GitHub** - La visibilidad debe ser `public`. 2. **Archivos instalables en el ultimo release** - El ultimo release debe contener al menos un archivo con una extension compatible: - Android: `.apk` - Windows: `.exe`, `.msi` - macOS: `.dmg`, `.pkg` - Linux: `.deb`, `.rpm`, `.AppImage` - GitHub Store ignora los artefactos de codigo fuente generados automaticamente (`Source code (zip)` / `Source code (tar.gz)`). 3. **Descubrible mediante busqueda / topics** - Los repositorios se obtienen a traves de la API publica de busqueda de GitHub. - Los topics, el lenguaje y la descripcion ayudan en la clasificacion: - Apps para Android: topics como `android`, `mobile`, `apk`. - Apps de escritorio: topics como `desktop`, `windows`, `linux`, `macos`, `compose-desktop`, `electron`. - Tener al menos algunas estrellas aumenta la probabilidad de aparecer en las secciones Trending/Hot Release/Most Popular. Si tu repositorio cumple estas condiciones, GitHub Store puede encontrarlo a traves de la busqueda y mostrarlo automaticamente, sin necesidad de envio manual. --- ## ✅ Ventajas / ¿Por que usar GitHub Store? - **No mas busquedas en los releases de GitHub** Ve solo los repositorios que realmente distribuyen binarios para tu plataforma. - **Sabe lo que instalaste** Rastrea las apps instaladas a traves de GitHub Store (Android) y destaca cuando hay nuevas releases disponibles, para que puedas actualizarlas sin buscar de nuevo en GitHub. - **Siempre actualizado** Las instalaciones usan por defecto el ultimo release publicado, con la opcion de explorar e instalar desde cualquier release anterior mediante el selector de releases. - **Codigo abierto y extensible** Escrito en KMP con una separacion clara entre red, logica de dominio e interfaz de usuario — facil de bifurcar, extender o adaptar. --- ## 🔐 Certificado de Firma del APK de GitHub Store Todas las releases oficiales de GitHub Store estan firmadas con la siguiente huella digital del certificado: SHA-256: `B7:F2:8E:19:8E:48:C1:93:B0:38:C6:5D:92:DD:F7:BC:07:7B:0D:B5:9E:BC:9B:25:0A:6D:AC:48:C1:18:03:CA` --- ## 🔑 Configuracion de GitHub OAuth **Resumen** 1. Crea una GitHub OAuth App 2. Copia el **Client ID** 3. Ponlo en `local.properties`
Mostrar guia completa de configuracion
### 1 - Crear una GitHub OAuth App Ve a: **GitHub → Settings → Developer settings → OAuth Apps → New OAuth App** | Campo | Valor | | ------------------------------ | ------------------------------------------- | | **Application name** | Lo que prefieras (p.ej. *GitHub Store Dev*) | | **Homepage URL** | `https://github.com/username/repo_name` | | **Authorization callback URL** | `githubstore://callback` | Luego haz clic en **Create application**. ### 2 - Copiar tu Client ID Despues de crear la app, GitHub mostrara: - **Client ID** ← esto es lo que necesitas - **Client Secret** ← ❗ NO es necesario para este proyecto ### 3 - Anadirlo a tu Proyecto Abre el archivo `local.properties` de tu proyecto (raiz del proyecto) y anade: ```properties GITHUB_CLIENT_ID=YOUR_CLIENT_ID_HERE ``` ### 4 - Sincronizar y Ejecutar Sincroniza el proyecto y ejecuta la app. Ahora deberias poder iniciar sesion con GitHub. ### ❗ Notas Importantes - `local.properties` **no se sube a Git**, por lo que tu Client ID permanece local. - Este proyecto solo necesita el **Client ID** (no el Client Secret). - Cada desarrollador deberia crear su propia OAuth app para desarrollo.
--- ## ☕ Apoya el proyecto GitHub Store esta construido y mantenido por un estudiante de instituto. Tu apoyo le ayuda a: ✅ **Mantener la app libre de errores** — responder a issues y enviar correcciones rapidamente ✅ **Anadir funciones solicitadas por la comunidad** — implementar lo que los usuarios realmente necesitan ### 💖 Formas de Apoyar Buy Me a Coffee GitHub Sponsors **¿No puedes patrocinar ahora mismo?** ¡No pasa nada! Tambien puedes ayudar: - ⭐ **Dando una estrella a este repositorio** — ayuda a otros a descubrir GitHub Store - 🐛 **Reportando errores** — mejora la app para todos - 📢 **Compartiendolo con amigos** — difunde la palabra entre otros desarrolladores y amigos! - 💬 **Uniendote a nuestro [Discord](https://discord.gg/x9Cvh2Z9qS)** — tus comentarios moldean la hoja de ruta Cada forma de apoyo — financiera o no — significa mucho y mantiene este proyecto vivo. ¡Gracias! --- ## ⚠️ Descargo de Responsabilidad GitHub Store solo te ayuda a descubrir y descargar archivos de releases que ya estan publicados en GitHub por desarrolladores externos. Los contenidos, la seguridad y el comportamiento de esas descargas son responsabilidad exclusiva de sus respectivos autores y distribuidores, no de este proyecto. Al usar GitHub Store, entiendes y aceptas que instalas y ejecutas cualquier software descargado bajo tu propia responsabilidad. Este proyecto no revisa, valida ni garantiza que ningun instalador sea seguro, este libre de malware o sea adecuado para ningun proposito en particular. --- ## Historial de Estrellas Star History Chart ![Alt](https://repobeats.axiom.co/api/embed/20367dca127572e9c47db33850979d78df2c6a8b.svg "Repobeats analytics image") ## 📄 Licencia GitHub Store se distribuira bajo la **Licencia Apache, Version 2.0**. ``` Copyright 2025 rainxchzed Licensed under the Apache License, Version 2.0 (the "License"); you may not use this project 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: docs/README-FR.md ================================================

# GitHub Store

API Kotlin Compose Multiplatform material





OpenHub-Store%2FGitHub-Store | Trendshift Featured|HelloGitHub

# 🗺️ Aperçu du Projet GitHub Store est une boutique d'applications multiplateforme dédiée aux releases GitHub, conçue pour simplifier la découverte et l'installation de logiciels open source. Elle détecte automatiquement les binaires installables (APK, EXE, DMG, AppImage, DEB, RPM), offre une installation en un clic, suit les mises à jour et présente les informations des dépôts dans une interface épurée, façon boutique d'applications. Développée avec Kotlin Multiplatform et Compose Multiplatform pour les plateformes Android et Desktop.
> [!CAUTION] > Android libre et open source est menacé. Google va transformer Android en une plateforme verrouillée, restreignant votre liberté fondamentale d'installer les applications de votre choix. Faites entendre votre voix – [keepandroidopen.org](https://keepandroidopen.org/).

# 📔 Wiki et Ressources Consultez le [Wiki](https://github.com/OpenHub-Store/GitHub-Store/wiki) de GitHub Store pour la FAQ et des informations utiles 🌐 **Site web :** [github-store.org](https://github-store.org) 💬 **Discord :** [Rejoindre la communauté](https://discord.gg/x9Cvh2Z9qS) 📜 **Politique de confidentialité :** [github-store.org/privacy-policy](https://github-store.org/privacy-policy/)
---
### 📋 Mentions Légales GitHub Store est un projet open source indépendant, non affilié à GitHub, Inc. Le nom décrit la fonctionnalité de l'application (découverte des releases GitHub) et n'implique aucune appropriation de marque commerciale. GitHub® est une marque déposée de GitHub, Inc.
---

# 🔃 Téléchargement

Get it on Obtainium Get it on GitHub Store

> [!IMPORTANT] > **Utilisateurs macOS :** Il est possible qu'un avertissement indique qu'Apple ne peut pas vérifier GitHub Store. Cela est dû au fait que l'application est distribuée hors de l'App Store et n'est pas encore notariée. Autorisez-la via Réglages Système → Confidentialité et sécurité → Ouvrir quand même. ---

# 🏆 Mis en Avant Dans

Featured by HowToMen
HowToMen : Top 20 Meilleures Applications Android 2026 | Top 12 Boutiques d'Applications Meilleures que le Google Play Store
HelloGitHub : Projet Mis en Avant

--- ## 🚀 Fonctionnalités - **Découverte intelligente** - Sections d'accueil pour les projets "Trending", "Hot Release" et "Most Popular" avec des filtres temporels. - Seuls les dépôts possédant des fichiers installables valides sont affichés. - Score thématique tenant compte de la plateforme pour que les utilisateurs Android/desktop voient les applications pertinentes en premier. - Recherche améliorée avec un meilleur classement par pertinence et de meilleures performances. - **Navigateur de releases et installations** - Sélecteur de releases pour parcourir et installer depuis n'importe quelle release, pas seulement la dernière. - Récupère toutes les releases de chaque dépôt. - Action unique "Installer la dernière version", plus une liste déroulante de toutes les releases disponibles et leurs installeurs. - Option d'installation manuelle avec vérifications automatiques de compatibilité. - **Écran de détails enrichi** - Nom de l'application, version et action de partage. - Étoiles, forks, issues ouverts. - Contenu du README rendu ("À propos de cette application"). - Notes de release avec formatage Markdown pour toute release sélectionnée. - Liste des installeurs avec étiquettes de plateforme et tailles de fichiers. - Prise en charge des liens profonds — ouvrez les détails d'un dépôt directement via URL. - Écran de profil du développeur pour explorer ses dépôts et son activité. - **Gestion des applications** - Ouvrez, désinstallez et rétrogradez les applications installées directement depuis GitHub Store. - Android : correspondance d'architecture APK (armv7/armv8), surveillance des paquets et suivi des mises à jour. - Desktop (Windows/macOS/Linux) : télécharge les installeurs dans le dossier Téléchargements et les ouvre avec le gestionnaire par défaut. - **Dépôts suivis** - Sauvegardez et parcourez vos dépôts GitHub favoris depuis l'application. - **Réseau et performances** - Prise en charge du proxy dynamique pour un routage réseau configurable. - Système de cache amélioré pour un chargement plus rapide et une utilisation réduite de l'API. --- ## 🔍 Comment mon application apparaît-elle dans GitHub Store ? GitHub Store n'utilise aucune indexation privée ni règle de curation manuelle. Votre projet peut apparaître automatiquement s'il respecte ces conditions : 1. **Dépôt public sur GitHub** - La visibilité doit être `public`. 2. **Fichiers installables dans la dernière release** - La dernière release doit contenir au moins un fichier avec une extension prise en charge : - Android : `.apk` - Windows : `.exe`, `.msi` - macOS : `.dmg`, `.pkg` - Linux : `.deb`, `.rpm`, `.AppImage` - GitHub Store ignore les archives de code source générées automatiquement (`Source code (zip)` / `Source code (tar.gz)`). 3. **Découvrable via la recherche / les topics** - Les dépôts sont récupérés via l'API de recherche publique de GitHub. - Les topics, le langage et la description influencent le classement : - Applications Android : topics comme `android`, `mobile`, `apk`. - Applications desktop : topics comme `desktop`, `windows`, `linux`, `macos`, `compose-desktop`, `electron`. - Avoir au moins quelques étoiles augmente la probabilité d'apparaître dans les sections Trending/Hot Release/Most Popular. Si votre dépôt remplit ces conditions, GitHub Store peut le trouver via la recherche et l'afficher automatiquement — aucune soumission manuelle n'est requise. --- ## ✅ Avantages / Pourquoi utiliser GitHub Store ? - **Fini de fouiller dans les releases GitHub** Ne voyez que les dépôts qui distribuent réellement des binaires pour votre plateforme. - **Sait ce que vous avez installé** Suit les applications installées via GitHub Store (Android) et signale les nouvelles releases disponibles, pour que vous puissiez les mettre à jour sans retourner chercher sur GitHub. - **Toujours à jour** Les installations utilisent par défaut la dernière release publiée, avec la possibilité de parcourir et d'installer depuis n'importe quelle release précédente via le sélecteur de releases. - **Open source et extensible** Écrit en KMP avec une séparation claire entre le réseau, la logique métier et l'interface — facile à forker, étendre ou adapter. --- ## 🔐 Certificat de Signature APK de GitHub Store Toutes les releases officielles de GitHub Store sont signées avec l'empreinte de certificat suivante : SHA-256 : `B7:F2:8E:19:8E:48:C1:93:B0:38:C6:5D:92:DD:F7:BC:07:7B:0D:B5:9E:BC:9B:25:0A:6D:AC:48:C1:18:03:CA` --- ## 🔑 Configuration de GitHub OAuth **En résumé** 1. Créez une GitHub OAuth App 2. Copiez le **Client ID** 3. Placez-le dans `local.properties`
Afficher le guide de configuration complet
### 1 - Créer une GitHub OAuth App Rendez-vous sur : **GitHub → Settings → Developer settings → OAuth Apps → New OAuth App** | Champ | Valeur | | ------------------------------ | --------------------------------------------- | | **Application name** | Ce que vous voulez (ex. *GitHub Store Dev*) | | **Homepage URL** | `https://github.com/username/repo_name` | | **Authorization callback URL** | `githubstore://callback` | Puis cliquez sur **Create application**. ### 2 - Copier votre Client ID Après la création, GitHub affichera : - **Client ID** ← c'est ce dont vous avez besoin - **Client Secret** ← ❗ NON requis pour ce projet ### 3 - L'ajouter à votre projet Ouvrez le fichier `local.properties` de votre projet (à la racine) et ajoutez : ```properties GITHUB_CLIENT_ID=YOUR_CLIENT_ID_HERE ``` ### 4 - Synchroniser et lancer Synchronisez le projet et lancez l'application. Vous devriez maintenant pouvoir vous connecter avec GitHub. ### ❗ Remarques importantes - `local.properties` **n'est pas versionné dans Git**, votre Client ID reste donc local. - Ce projet n'a besoin que du **Client ID** (pas du Client Secret). - Chaque développeur devrait créer sa propre OAuth App pour le développement.
--- ## ☕ Soutenir le projet GitHub Store est développé et maintenu par un lycéen. Votre soutien l'aide à : ✅ **Garder l'application sans bugs** — répondre aux issues et publier des correctifs rapidement ✅ **Ajouter des fonctionnalités demandées par la communauté** — implémenter ce dont les utilisateurs ont vraiment besoin ### 💖 Façons de Soutenir Buy Me a Coffee GitHub Sponsors **Vous ne pouvez pas sponsoriser maintenant ?** Pas de problème ! Vous pouvez tout de même aider en : - ⭐ **Mettant une étoile à ce dépôt** — aide les autres à découvrir GitHub Store - 🐛 **Signalant des bugs** — améliore l'application pour tout le monde - 📢 **Partageant avec vos amis** — faites passer le mot à d'autres développeurs et proches ! - 💬 **Rejoignant notre [Discord](https://discord.gg/x9Cvh2Z9qS)** — vos retours façonnent la feuille de route Chaque forme de soutien — financière ou non — compte énormément et maintient ce projet en vie. Merci ! --- ## ⚠️ Avertissement GitHub Store vous aide uniquement à découvrir et télécharger des fichiers de releases déjà publiés sur GitHub par des développeurs tiers. Le contenu, la sécurité et le comportement de ces téléchargements relèvent de la seule responsabilité de leurs auteurs et distributeurs respectifs, et non de ce projet. En utilisant GitHub Store, vous comprenez et acceptez que vous installez et exécutez tout logiciel téléchargé à vos propres risques. Ce projet ne révise, ne valide ni ne garantit qu'un installeur est sûr, exempt de logiciels malveillants ou adapté à un usage particulier. --- ## Historique des Étoiles Star History Chart ![Alt](https://repobeats.axiom.co/api/embed/20367dca127572e9c47db33850979d78df2c6a8b.svg "Repobeats analytics image") ## 📄 Licence GitHub Store sera publié sous la **Licence Apache, Version 2.0**. ``` Copyright 2025 rainxchzed Licensed under the Apache License, Version 2.0 (the "License"); you may not use this project 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: docs/README-HI.md ================================================

# GitHub Store

API Kotlin Compose Multiplatform material





OpenHub-Store%2FGitHub-Store | Trendshift Featured|HelloGitHub

# 🗺️ परियोजना का अवलोकन GitHub Store एक क्रॉस-प्लेटफ़ॉर्म ऐप स्टोर है जो GitHub रिलीज़ के लिए बनाया गया है। यह ओपन-सोर्स सॉफ़्टवेयर खोजने और इंस्टॉल करने को आसान बनाता है। यह इंस्टॉल करने योग्य बायनरी फ़ाइलों (APK, EXE, DMG, AppImage, DEB, RPM) को स्वचालित रूप से पहचानता है, वन-क्लिक इंस्टॉलेशन प्रदान करता है, अपडेट को ट्रैक करता है और रिपॉजिटरी की जानकारी को एक साफ ऐप-स्टोर शैली के इंटरफ़ेस में प्रस्तुत करता है। Android और Desktop प्लेटफ़ॉर्म के लिए Kotlin Multiplatform और Compose Multiplatform के साथ बनाया गया है।
> [!CAUTION] > मुक्त और ओपन-सोर्स Android खतरे में है। Google, Android को एक बंद प्लेटफ़ॉर्म में बदल देगा, जो आपकी पसंद के ऐप्स इंस्टॉल करने की आपकी मूलभूत स्वतंत्रता को प्रतिबंधित करेगा। अपनी आवाज़ उठाएं – [keepandroidopen.org](https://keepandroidopen.org/).

# 📔 Wiki और संसाधन FAQ और उपयोगी जानकारी के लिए GitHub Store [Wiki](https://github.com/OpenHub-Store/GitHub-Store/wiki) देखें 🌐 **वेबसाइट:** [github-store.org](https://github-store.org) 💬 **Discord:** [समुदाय से जुड़ें](https://discord.gg/x9Cvh2Z9qS) 📜 **गोपनीयता नीति:** [github-store.org/privacy-policy](https://github-store.org/privacy-policy/)
---
### 📋 कानूनी नोटिस GitHub Store एक स्वतंत्र ओपन-सोर्स प्रोजेक्ट है जो GitHub, Inc. से संबद्ध नहीं है। यह नाम ऐप की कार्यक्षमता (GitHub रिलीज़ खोजना) का वर्णन करता है और इसका अर्थ ट्रेडमार्क स्वामित्व नहीं है। GitHub® GitHub, Inc. का एक पंजीकृत ट्रेडमार्क है।
---

# 🔃 डाउनलोड

Get it on Obtainium Get it on GitHub Store

> [!IMPORTANT] > **macOS उपयोगकर्ता:** आपको एक चेतावनी दिख सकती है कि Apple, GitHub Store को सत्यापित नहीं कर सकता। ऐसा इसलिए होता है क्योंकि ऐप App Store के बाहर वितरित की जाती है और अभी तक notarized नहीं है। System Settings → Privacy & Security → Open Anyway के ज़रिए इसे अनुमति दें। ---

# 🏆 मीडिया में शामिल

Featured by HowToMen
HowToMen: 2026 के शीर्ष 20 सर्वश्रेष्ठ Android ऐप्स | Google Play Store से बेहतर शीर्ष 12 ऐप स्टोर
HelloGitHub: विशेष रूप से प्रदर्शित प्रोजेक्ट

--- ## 🚀 सुविधाएँ - **स्मार्ट खोज** - समय-आधारित फ़िल्टर के साथ "Trending", "Hot Release" और "Most Popular" प्रोजेक्ट के होम सेक्शन। - केवल वे रिपॉजिटरी दिखाई जाती हैं जिनमें वैध इंस्टॉल योग्य फ़ाइलें हों। - प्लेटफ़ॉर्म-जागरूक टॉपिक स्कोरिंग ताकि Android/डेस्कटॉप उपयोगकर्ताओं को पहले प्रासंगिक ऐप्स दिखें। - बेहतर प्रासंगिकता रैंकिंग और प्रदर्शन के साथ नई सर्च। - **रिलीज़ ब्राउज़र और इंस्टॉलेशन** - रिलीज़ सेलेक्टर जो किसी भी रिलीज़ से ब्राउज़ और इंस्टॉल करने की सुविधा देता है, न कि केवल नवीनतम से। - प्रत्येक रिपॉजिटरी की सभी रिलीज़ लाता है। - एकल "नवीनतम संस्करण इंस्टॉल करें" क्रिया, साथ ही सभी उपलब्ध रिलीज़ और उनके इंस्टॉलर की विस्तार योग्य सूची। - स्वचालित संगतता जांच के साथ मैन्युअल इंस्टॉलेशन विकल्प। - **समृद्ध विवरण स्क्रीन** - ऐप का नाम, संस्करण और शेयर करने की क्रिया। - स्टार, फ़ोर्क, खुले इश्यू। - रेंडर किया गया README कंटेंट ("इस ऐप के बारे में")। - किसी भी चुनी हुई रिलीज़ के लिए Markdown फ़ॉर्मेटिंग के साथ रिलीज़ नोट्स। - प्लेटफ़ॉर्म लेबल और फ़ाइल आकारों के साथ इंस्टॉलर की सूची। - डीप लिंकिंग सपोर्ट — URL के ज़रिए सीधे रिपॉजिटरी विवरण खोलें। - किसी डेवलपर के रिपॉजिटरी और गतिविधि को एक्सप्लोर करने के लिए डेवलपर प्रोफ़ाइल स्क्रीन। - **ऐप प्रबंधन** - इंस्टॉल किए गए ऐप्स को GitHub Store से सीधे खोलें, अनइंस्टॉल करें और डाउनग्रेड करें। - Android: APK आर्किटेक्चर मिलान (armv7/armv8), पैकेज मॉनिटरिंग और अपडेट ट्रैकिंग। - Desktop (Windows/macOS/Linux): इंस्टॉलर को उपयोगकर्ता के Downloads फ़ोल्डर में डाउनलोड करता है और डिफ़ॉल्ट हैंडलर से खोलता है। - **स्टार की गई रिपॉजिटरी** - ऐप के भीतर से अपनी GitHub स्टार की हुई रिपॉजिटरी सेव करें और ब्राउज़ करें। - **नेटवर्क और प्रदर्शन** - कॉन्फ़िगर करने योग्य नेटवर्क रूटिंग के लिए डायनामिक प्रॉक्सी सपोर्ट। - तेज़ लोडिंग और कम API उपयोग के लिए उन्नत कैशिंग सिस्टम। --- ## 🔍 मेरी ऐप GitHub Store में कैसे दिखेगी? GitHub Store किसी भी निजी इंडेक्सिंग या मैन्युअल क्यूरेशन नियमों का उपयोग नहीं करता। आपका प्रोजेक्ट स्वचालित रूप से दिख सकता है यदि यह इन शर्तों को पूरा करता है: 1. **GitHub पर सार्वजनिक रिपॉजिटरी** - विज़िबिलिटी `public` होनी चाहिए। 2. **नवीनतम रिलीज़ में इंस्टॉल योग्य फ़ाइलें** - नवीनतम रिलीज़ में समर्थित एक्सटेंशन वाली कम से कम एक फ़ाइल होनी चाहिए: - Android: `.apk` - Windows: `.exe`, `.msi` - macOS: `.dmg`, `.pkg` - Linux: `.deb`, `.rpm`, `.AppImage` - GitHub Store GitHub के स्वचालित रूप से जनरेट किए गए सोर्स आर्काइव (`Source code (zip)` / `Source code (tar.gz)`) को अनदेखा करता है। 3. **सर्च / टॉपिक द्वारा खोजे जाने योग्य** - रिपॉजिटरी GitHub की सार्वजनिक सर्च API के माध्यम से लाई जाती हैं। - टॉपिक, भाषा और विवरण रैंकिंग में मदद करते हैं: - Android ऐप्स: `android`, `mobile`, `apk` जैसे टॉपिक। - Desktop ऐप्स: `desktop`, `windows`, `linux`, `macos`, `compose-desktop`, `electron` जैसे टॉपिक। - कम से कम कुछ स्टार होने से Trending/Hot Release/Most Popular सेक्शन में दिखने की संभावना बढ़ जाती है। यदि आपकी रिपॉजिटरी इन शर्तों को पूरा करती है, तो GitHub Store इसे सर्च के माध्यम से खोज सकता है और स्वचालित रूप से दिखा सकता है — किसी मैन्युअल सबमिशन की आवश्यकता नहीं। --- ## ✅ फ़ायदे / GitHub Store क्यों उपयोग करें? - **GitHub रिलीज़ में हाथ से खोजना बंद** केवल वे रिपॉजिटरी देखें जो आपके प्लेटफ़ॉर्म के लिए वास्तव में बायनरी शिप करती हैं। - **जानता है कि आपने क्या इंस्टॉल किया है** GitHub Store (Android) के माध्यम से इंस्टॉल किए गए ऐप्स को ट्रैक करता है और जब नई रिलीज़ उपलब्ध होती हैं तो हाइलाइट करता है, ताकि आप GitHub पर दोबारा खोजे बिना उन्हें अपडेट कर सकें। - **हमेशा अप-टू-डेट** इंस्टॉलेशन डिफ़ॉल्ट रूप से नवीनतम प्रकाशित रिलीज़ का उपयोग करती है, रिलीज़ सेलेक्टर के माध्यम से किसी भी पिछली रिलीज़ से ब्राउज़ और इंस्टॉल करने के विकल्प के साथ। - **ओपन सोर्स और एक्सटेंसिबल** नेटवर्किंग, डोमेन लॉजिक और UI के बीच स्पष्ट अलगाव के साथ KMP में लिखा गया — फ़ोर्क करना, एक्सटेंड करना या अडैप्ट करना आसान। --- ## 🔐 GitHub Store APK साइनिंग सर्टिफ़िकेट सभी आधिकारिक GitHub Store रिलीज़ निम्नलिखित सर्टिफ़िकेट फ़िंगरप्रिंट के साथ साइन की जाती हैं: SHA-256: `B7:F2:8E:19:8E:48:C1:93:B0:38:C6:5D:92:DD:F7:BC:07:7B:0D:B5:9E:BC:9B:25:0A:6D:AC:48:C1:18:03:CA` --- ## 🔑 GitHub OAuth कॉन्फ़िगरेशन **संक्षेप में** 1. GitHub OAuth App बनाएं 2. **Client ID** कॉपी करें 3. इसे `local.properties` में डालें
पूरा सेटअप गाइड दिखाएं
### 1 - GitHub OAuth App बनाएं यहाँ जाएं: **GitHub → Settings → Developer settings → OAuth Apps → New OAuth App** | फ़ील्ड | मान | | ------------------------------ | ----------------------------------------------- | | **Application name** | कुछ भी (जैसे *GitHub Store Dev*) | | **Homepage URL** | `https://github.com/username/repo_name` | | **Authorization callback URL** | `githubstore://callback` | फिर **Create application** पर क्लिक करें। ### 2 - अपना Client ID कॉपी करें ऐप बनाने के बाद, GitHub दिखाएगा: - **Client ID** ← आपको यही चाहिए - **Client Secret** ← ❗ इस प्रोजेक्ट के लिए आवश्यक नहीं ### 3 - अपने प्रोजेक्ट में जोड़ें अपने प्रोजेक्ट की `local.properties` फ़ाइल (प्रोजेक्ट की रूट) खोलें और जोड़ें: ```properties GITHUB_CLIENT_ID=YOUR_CLIENT_ID_HERE ``` ### 4 - Sync करें और चलाएं प्रोजेक्ट को सिंक करें और ऐप चलाएं। अब आप GitHub से साइन इन कर सकेंगे। ### ❗ महत्वपूर्ण नोट्स - `local.properties` **Git में कमिट नहीं होता**, इसलिए आपका Client ID लोकल रहता है। - इस प्रोजेक्ट को केवल **Client ID** की ज़रूरत है (Client Secret की नहीं)। - प्रत्येक डेवलपर को विकास के लिए अपना खुद का OAuth App बनाना चाहिए।
--- ## ☕ प्रोजेक्ट को सपोर्ट करें GitHub Store एक हाई स्कूल छात्र द्वारा बनाया और मेंटेन किया जाता है। आपका सपोर्ट उन्हें इसमें मदद करता है: ✅ **ऐप को बग-मुक्त रखना** — इश्यू का जवाब देना और जल्दी फ़िक्स शिप करना ✅ **कम्युनिटी द्वारा अनुरोधित सुविधाएं जोड़ना** — वह लागू करना जो उपयोगकर्ताओं को वास्तव में चाहिए ### 💖 सपोर्ट के तरीके Buy Me a Coffee GitHub Sponsors **अभी स्पॉन्सर नहीं कर सकते?** कोई बात नहीं! आप फिर भी इन तरीकों से मदद कर सकते हैं: - ⭐ **इस रिपॉजिटरी को स्टार करें** — दूसरों को GitHub Store खोजने में मदद करता है - 🐛 **बग रिपोर्ट करें** — ऐप को सभी के लिए बेहतर बनाता है - 📢 **दोस्तों के साथ शेयर करें** — अन्य डेवलपर्स और दोस्तों को बताएं! - 💬 **हमारे [Discord](https://discord.gg/x9Cvh2Z9qS) से जुड़ें** — आपकी प्रतिक्रिया रोडमैप को आकार देती है हर प्रकार का सपोर्ट — वित्तीय हो या नहीं — बहुत मायने रखता है और इस प्रोजेक्ट को ज़िंदा रखता है। धन्यवाद! --- ## ⚠️ अस्वीकरण GitHub Store केवल तृतीय-पक्ष डेवलपर्स द्वारा GitHub पर पहले से प्रकाशित रिलीज़ फ़ाइलों को खोजने और डाउनलोड करने में आपकी मदद करता है। उन डाउनलोड की सामग्री, सुरक्षा और व्यवहार पूरी तरह से उनके संबंधित लेखकों और वितरकों की ज़िम्मेदारी है, इस प्रोजेक्ट की नहीं। GitHub Store का उपयोग करके, आप समझते और स्वीकार करते हैं कि आप किसी भी डाउनलोड किए गए सॉफ़्टवेयर को अपने जोखिम पर इंस्टॉल और चलाते हैं। यह प्रोजेक्ट किसी भी इंस्टॉलर की समीक्षा, सत्यापन या गारंटी नहीं करता कि वह सुरक्षित है, मैलवेयर-मुक्त है, या किसी विशेष उद्देश्य के लिए उपयुक्त है। --- ## Star इतिहास Star History Chart ![Alt](https://repobeats.axiom.co/api/embed/20367dca127572e9c47db33850979d78df2c6a8b.svg "Repobeats analytics image") ## 📄 लाइसेंस GitHub Store **Apache License, Version 2.0** के तहत रिलीज़ किया जाएगा। ``` Copyright 2025 rainxchzed Licensed under the Apache License, Version 2.0 (the "License"); you may not use this project 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: docs/README-IT.md ================================================

# GitHub Store

API Kotlin Compose Multiplatform material





OpenHub-Store%2FGitHub-Store | Trendshift Featured|HelloGitHub

# 🗺️ Panoramica del Progetto GitHub Store è uno store di applicazioni multipiattaforma per le release di GitHub, progettato per semplificare la scoperta e l'installazione di software open source. Rileva automaticamente i binari installabili (APK, EXE, DMG, AppImage, DEB, RPM), offre l'installazione con un solo clic, traccia gli aggiornamenti e presenta le informazioni sui repository in un'interfaccia pulita in stile app store. Sviluppato con Kotlin Multiplatform e Compose Multiplatform per le piattaforme Android e Desktop.
> [!CAUTION] > Android libero e open source è sotto minaccia. Google trasformerà Android in una piattaforma chiusa, limitando la tua libertà fondamentale di installare le app che preferisci. Fai sentire la tua voce – [keepandroidopen.org](https://keepandroidopen.org/).

# 📔 Wiki e Risorse Consulta la [Wiki](https://github.com/OpenHub-Store/GitHub-Store/wiki) di GitHub Store per le FAQ e informazioni utili 🌐 **Sito web:** [github-store.org](https://github-store.org) 💬 **Discord:** [Unisciti alla community](https://discord.gg/x9Cvh2Z9qS) 📜 **Informativa sulla privacy:** [github-store.org/privacy-policy](https://github-store.org/privacy-policy/)
---
### 📋 Note Legali GitHub Store è un progetto open source indipendente, non affiliato a GitHub, Inc. Il nome descrive la funzionalità dell'app (scoperta delle release di GitHub) e non implica alcuna proprietà di marchio. GitHub® è un marchio registrato di GitHub, Inc.
---

# 🔃 Download

Get it on Obtainium Get it on GitHub Store

> [!IMPORTANT] > **Utenti macOS:** Potresti vedere un avviso che indica che Apple non riesce a verificare GitHub Store. Ciò accade perché l'app è distribuita al di fuori dell'App Store e non è ancora notarizzata. Consentila tramite Impostazioni di Sistema → Privacy e Sicurezza → Apri comunque. ---

# 🏆 In Evidenza Su

Featured by HowToMen
HowToMen: Top 20 Migliori App Android 2026 | Top 12 Store di App Migliori del Google Play Store
HelloGitHub: Progetto in Evidenza

--- ## 🚀 Funzionalità - **Scoperta intelligente** - Sezioni nella home per i progetti "Trending", "Hot Release" e "Most Popular" con filtri temporali. - Vengono mostrati solo i repository con file installabili validi. - Punteggio dei topic consapevole della piattaforma, così gli utenti Android/desktop vedono prima le app rilevanti. - Ricerca rinnovata con classificazione per pertinenza e prestazioni migliorate. - **Browser delle release e installazioni** - Selettore di release per sfogliare e installare da qualsiasi release, non solo l'ultima. - Recupera tutte le release di ogni repository. - Azione unica "Installa l'ultima versione", più un elenco espandibile di tutte le release disponibili e i loro installer. - Opzione di installazione manuale con controlli automatici di compatibilità. - **Schermata dettagli ricca** - Nome dell'app, versione e azione di condivisione. - Stelle, fork, issue aperte. - Contenuto del README renderizzato ("Informazioni su questa app"). - Note di release con formattazione Markdown per qualsiasi release selezionata. - Elenco degli installer con etichette di piattaforma e dimensioni dei file. - Supporto ai deep link — apri i dettagli di un repository direttamente tramite URL. - Schermata del profilo sviluppatore per esplorare i repository e l'attività di uno sviluppatore. - **Gestione delle applicazioni** - Apri, disinstalla e declassa le app installate direttamente da GitHub Store. - Android: corrispondenza dell'architettura APK (armv7/armv8), monitoraggio dei pacchetti e tracciamento degli aggiornamenti. - Desktop (Windows/macOS/Linux): scarica gli installer nella cartella Download dell'utente e li apre con il gestore predefinito. - **Repository preferiti** - Salva e sfoglia i tuoi repository GitHub preferiti dall'app. - **Rete e prestazioni** - Supporto proxy dinamico per il routing di rete configurabile. - Sistema di cache migliorato per un caricamento più rapido e un minor utilizzo dell'API. --- ## 🔍 Come appare la mia app su GitHub Store? GitHub Store non utilizza alcun sistema di indicizzazione privato né regole di curazione manuale. Il tuo progetto può apparire automaticamente se rispetta queste condizioni: 1. **Repository pubblico su GitHub** - La visibilità deve essere `public`. 2. **File installabili nell'ultima release** - L'ultima release deve contenere almeno un file con un'estensione supportata: - Android: `.apk` - Windows: `.exe`, `.msi` - macOS: `.dmg`, `.pkg` - Linux: `.deb`, `.rpm`, `.AppImage` - GitHub Store ignora gli archivi del codice sorgente generati automaticamente (`Source code (zip)` / `Source code (tar.gz)`). 3. **Scopribile tramite ricerca / topic** - I repository vengono recuperati tramite l'API di ricerca pubblica di GitHub. - I topic, il linguaggio e la descrizione influenzano il ranking: - App Android: topic come `android`, `mobile`, `apk`. - App desktop: topic come `desktop`, `windows`, `linux`, `macos`, `compose-desktop`, `electron`. - Avere almeno alcune stelle aumenta la probabilità di apparire nelle sezioni Trending/Hot Release/Most Popular. Se il tuo repository soddisfa queste condizioni, GitHub Store può trovarlo tramite la ricerca e mostrarlo automaticamente — nessuna submission manuale richiesta. --- ## ✅ Vantaggi / Perché usare GitHub Store? - **Niente più ricerche tra le release di GitHub** Vedi solo i repository che distribuiscono effettivamente binari per la tua piattaforma. - **Sa cosa hai installato** Traccia le app installate tramite GitHub Store (Android) e segnala quando sono disponibili nuove release, così puoi aggiornarle senza dover tornare a cercare su GitHub. - **Sempre aggiornato** Le installazioni utilizzano per impostazione predefinita l'ultima release pubblicata, con la possibilità di sfogliare e installare qualsiasi release precedente tramite il selettore di release. - **Open source ed estensibile** Scritto in KMP con una netta separazione tra rete, logica di dominio e UI — facile da forkare, estendere o adattare. --- ## 🔐 Certificato di Firma APK di GitHub Store Tutte le release ufficiali di GitHub Store sono firmate con la seguente impronta digitale del certificato: SHA-256: `B7:F2:8E:19:8E:48:C1:93:B0:38:C6:5D:92:DD:F7:BC:07:7B:0D:B5:9E:BC:9B:25:0A:6D:AC:48:C1:18:03:CA` --- ## 🔑 Configurazione di GitHub OAuth **In sintesi** 1. Crea una GitHub OAuth App 2. Copia il **Client ID** 3. Inseriscilo in `local.properties`
Mostra la guida completa alla configurazione
### 1 - Creare una GitHub OAuth App Vai su: **GitHub → Settings → Developer settings → OAuth Apps → New OAuth App** | Campo | Valore | | ------------------------------ | ----------------------------------------------- | | **Application name** | Quello che preferisci (es. *GitHub Store Dev*) | | **Homepage URL** | `https://github.com/username/repo_name` | | **Authorization callback URL** | `githubstore://callback` | Poi clicca su **Create application**. ### 2 - Copiare il Client ID Dopo la creazione, GitHub mostrerà: - **Client ID** ← questo è ciò di cui hai bisogno - **Client Secret** ← ❗ NON richiesto per questo progetto ### 3 - Aggiungerlo al progetto Apri il file `local.properties` del tuo progetto (nella root) e aggiungi: ```properties GITHUB_CLIENT_ID=YOUR_CLIENT_ID_HERE ``` ### 4 - Sincronizzare ed eseguire Sincronizza il progetto ed esegui l'app. Ora dovresti poter accedere con GitHub. ### ❗ Note importanti - `local.properties` **non è incluso in Git**, quindi il tuo Client ID rimane locale. - Questo progetto ha bisogno solo del **Client ID** (non del Client Secret). - Ogni sviluppatore dovrebbe creare la propria OAuth App per lo sviluppo.
--- ## ☕ Supporta il progetto GitHub Store è sviluppato e mantenuto da uno studente liceale. Il tuo supporto lo aiuta a: ✅ **Mantenere l'app priva di bug** — rispondere alle issue e pubblicare correzioni rapidamente ✅ **Aggiungere funzionalità richieste dalla community** — implementare ciò di cui gli utenti hanno davvero bisogno ### 💖 Come Supportare Buy Me a Coffee GitHub Sponsors **Non puoi sponsorizzare in questo momento?** Nessun problema! Puoi comunque aiutare: - ⭐ **Mettendo una stella a questo repository** — aiuta gli altri a scoprire GitHub Store - 🐛 **Segnalando bug** — migliora l'app per tutti - 📢 **Condividendo con gli amici** — spargi la voce tra altri sviluppatori e amici! - 💬 **Unendoti al nostro [Discord](https://discord.gg/x9Cvh2Z9qS)** — il tuo feedback plasma la roadmap Ogni forma di supporto — finanziario o meno — significa moltissimo e mantiene vivo questo progetto. Grazie! --- ## ⚠️ Dichiarazione di Non Responsabilità GitHub Store ti aiuta soltanto a scoprire e scaricare file di release già pubblicati su GitHub da sviluppatori terzi. Il contenuto, la sicurezza e il comportamento di questi download sono di esclusiva responsabilità dei rispettivi autori e distributori, non di questo progetto. Usando GitHub Store, capisci e accetti che installi ed esegui qualsiasi software scaricato a tuo rischio e pericolo. Questo progetto non revisiona, valida né garantisce che un installer sia sicuro, privo di malware o adatto a uno scopo specifico. --- ## Cronologia delle Stelle Star History Chart ![Alt](https://repobeats.axiom.co/api/embed/20367dca127572e9c47db33850979d78df2c6a8b.svg "Repobeats analytics image") ## 📄 Licenza GitHub Store sarà rilasciato sotto la **Licenza Apache, Versione 2.0**. ``` Copyright 2025 rainxchzed Licensed under the Apache License, Version 2.0 (the "License"); you may not use this project 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: docs/README-JA.md ================================================

# GitHub Store

API Kotlin Compose Multiplatform material





OpenHub-Store%2FGitHub-Store | Trendshift Featured|HelloGitHub

# 🗺️ プロジェクト概要 GitHub Store は、オープンソースソフトウェアの発見とインストールを簡単にするために設計された、GitHub リリース向けのクロスプラットフォームアプリストアです。インストール可能なバイナリ(APK、EXE、DMG、AppImage、DEB、RPM)を自動的に検出し、ワンクリックインストール、アップデート追跡、そしてアプリストア風のクリーンなインターフェースでリポジトリ情報を提供します。 Android および Desktop プラットフォーム向けに Kotlin Multiplatform と Compose Multiplatform で構築されています。
> [!CAUTION] > 自由でオープンソースな Android が危機に瀕しています。Google は Android を閉鎖的なプラットフォームに変え、好きなアプリをインストールするというあなたの基本的な自由を制限しようとしています。声を上げてください – [keepandroidopen.org](https://keepandroidopen.org/).

# 📔 Wiki とリソース FAQ や役立つ情報は GitHub Store の [Wiki](https://github.com/OpenHub-Store/GitHub-Store/wiki) をご覧ください 🌐 **ウェブサイト:** [github-store.org](https://github-store.org) 💬 **Discord:** [コミュニティに参加する](https://discord.gg/x9Cvh2Z9qS) 📜 **プライバシーポリシー:** [github-store.org/privacy-policy](https://github-store.org/privacy-policy/)
---
### 📋 法的事項 GitHub Store は GitHub, Inc. と無関係の独立したオープンソースプロジェクトです。 この名称はアプリの機能(GitHub リリースの発見)を説明するものであり、商標の所有権を意味するものではありません。 GitHub® は GitHub, Inc. の登録商標です。
---

# 🔃 ダウンロード

Get it on Obtainium Get it on GitHub Store

> [!IMPORTANT] > **macOS ユーザーの方へ:** Apple が GitHub Store を検証できないという警告が表示される場合があります。これはアプリが App Store 外で配布されており、まだ公証されていないためです。システム設定 → プライバシーとセキュリティ → このまま開く から許可してください。 ---

# 🏆 掲載メディア

Featured by HowToMen
HowToMen: 2026年 おすすめAndroidアプリ TOP 20 | Google Play ストアより優れたアプリストア TOP 12
HelloGitHub: 注目プロジェクト

--- ## 🚀 機能 - **スマートな発見** - 時間ベースのフィルター付きで「Trending」「Hot Release」「Most Popular」プロジェクトのホームセクション。 - 有効なインストール可能ファイルを持つリポジトリのみが表示されます。 - Android/デスクトップユーザーが関連アプリを最初に見られるよう、プラットフォームを考慮したトピックスコアリング。 - 関連性ランキングとパフォーマンスが向上した刷新された検索。 - **リリースブラウザとインストール** - 最新版だけでなく、任意のリリースから閲覧・インストールできるリリースセレクター。 - 各リポジトリのすべてのリリースを取得。 - 「最新版をインストール」ワンアクション、および利用可能なすべてのリリースとインストーラーの展開可能なリスト。 - 自動互換性チェック付きの手動インストールオプション。 - **充実した詳細画面** - アプリ名、バージョン、共有アクション。 - スター数、フォーク数、オープンイシュー数。 - レンダリングされた README コンテンツ(「このアプリについて」)。 - 選択した任意のリリースの Markdown 形式リリースノート。 - プラットフォームラベルとファイルサイズ付きのインストーラーリスト。 - ディープリンクサポート — URL からリポジトリ詳細を直接開く。 - 開発者のリポジトリと活動を探索するための開発者プロフィール画面。 - **アプリ管理** - インストール済みアプリを GitHub Store から直接開く、アンインストール、ダウングレード。 - Android:APK アーキテクチャマッチング(armv7/armv8)、パッケージ監視、アップデート追跡。 - Desktop(Windows/macOS/Linux):インストーラーをユーザーのダウンロードフォルダーにダウンロードし、デフォルトハンドラーで開く。 - **スター付きリポジトリ** - アプリ内から GitHub のスター付きリポジトリを保存・閲覧。 - **ネットワークとパフォーマンス** - 設定可能なネットワークルーティングのための動的プロキシサポート。 - より高速な読み込みと API 使用量削減のための強化されたキャッシュシステム。 --- ## 🔍 自分のアプリを GitHub Store に表示させるには? GitHub Store はプライベートインデックスや手動キュレーションルールを一切使用していません。 以下の条件を満たせば、プロジェクトは自動的に表示される可能性があります: 1. **GitHub 上の公開リポジトリ** - 可視性は `public` に設定されている必要があります。 2. **最新リリースにインストール可能なファイル** - 最新リリースに、サポートされている拡張子のファイルが少なくとも 1 つ含まれている必要があります: - Android: `.apk` - Windows: `.exe`, `.msi` - macOS: `.dmg`, `.pkg` - Linux: `.deb`, `.rpm`, `.AppImage` - GitHub Store は GitHub が自動生成するソースコードアーカイブ(`Source code (zip)` / `Source code (tar.gz)`)を無視します。 3. **検索 / トピックスによる発見可能性** - リポジトリは GitHub の公開検索 API 経由で取得されます。 - トピックス、言語、説明がランキングに影響します: - Android アプリ:`android`、`mobile`、`apk` などのトピックス。 - デスクトップアプリ:`desktop`、`windows`、`linux`、`macos`、`compose-desktop`、`electron` などのトピックス。 - スターが少しでもあると、Trending/Hot Release/Most Popular セクションに表示される可能性が高まります。 リポジトリがこれらの条件を満たしている場合、GitHub Store は検索を通じてそれを見つけ、自動的に表示できます — 手動での申請は不要です。 --- ## ✅ メリット / GitHub Store を使う理由 - **GitHub リリースを手動で探す手間がなくなる** あなたのプラットフォーム向けにバイナリを実際に提供しているリポジトリだけを表示。 - **インストール済みアプリを把握している** GitHub Store 経由でインストールされたアプリ(Android)を追跡し、新しいリリースが利用可能になると通知。GitHub で再度検索することなく更新できます。 - **常に最新** インストールはデフォルトで最新公開リリースを使用。リリースセレクターを通じて以前のリリースも閲覧・インストール可能。 - **オープンソースで拡張可能** ネットワーク、ドメインロジック、UI が明確に分離された KMP で記述 — フォーク、拡張、カスタマイズが簡単。 --- ## 🔐 GitHub Store APK 署名証明書 すべての公式 GitHub Store リリースは以下の証明書フィンガープリントで署名されています: SHA-256: `B7:F2:8E:19:8E:48:C1:93:B0:38:C6:5D:92:DD:F7:BC:07:7B:0D:B5:9E:BC:9B:25:0A:6D:AC:48:C1:18:03:CA` --- ## 🔑 GitHub OAuth の設定 **要約** 1. GitHub OAuth App を作成する 2. **Client ID** をコピーする 3. `local.properties` に記入する
完全なセットアップガイドを表示
### 1 - GitHub OAuth App の作成 以下に移動: **GitHub → Settings → Developer settings → OAuth Apps → New OAuth App** | フィールド | 値 | | ------------------------------ | ----------------------------------------------- | | **Application name** | 任意の名前(例:*GitHub Store Dev*) | | **Homepage URL** | `https://github.com/username/repo_name` | | **Authorization callback URL** | `githubstore://callback` | **Create application** をクリックします。 ### 2 - Client ID のコピー 作成後、GitHub は以下を表示します: - **Client ID** ← これが必要なもの - **Client Secret** ← ❗ このプロジェクトでは不要 ### 3 - プロジェクトへの追加 プロジェクトの `local.properties` ファイル(プロジェクトのルート)を開き、以下を追加: ```properties GITHUB_CLIENT_ID=YOUR_CLIENT_ID_HERE ``` ### 4 - 同期と実行 プロジェクトを同期してアプリを実行します。これで GitHub でサインインできるようになります。 ### ❗ 重要な注意事項 - `local.properties` は **Git にコミットされない** ため、Client ID はローカルに留まります。 - このプロジェクトに必要なのは **Client ID** のみです(Client Secret は不要)。 - 各開発者は開発用に独自の OAuth App を作成する必要があります。
--- ## ☕ プロジェクトを支援する GitHub Store は高校生によって開発・メンテナンスされています。あなたのサポートが以下を可能にします: ✅ **アプリをバグのない状態に保つ** — イシューに対応し、修正を迅速にリリースする ✅ **コミュニティからのリクエスト機能を追加する** — ユーザーが本当に必要とするものを実装する ### 💖 支援方法 Buy Me a Coffee GitHub Sponsors **今すぐスポンサーできない場合は?** 大丈夫!以下の方法でも支援できます: - ⭐ **このリポジトリにスターを付ける** — 他の人が GitHub Store を発見するのに役立ちます - 🐛 **バグを報告する** — アプリをすべての人のために改善します - 📢 **友人にシェアする** — 他の開発者や友人に広めてください! - 💬 **[Discord](https://discord.gg/x9Cvh2Z9qS) に参加する** — あなたのフィードバックがロードマップを形成します 金銭的か否かを問わず、あらゆる形のサポートが大きな意味を持ち、このプロジェクトを生き続けさせます。ありがとうございます! --- ## ⚠️ 免責事項 GitHub Store は、サードパーティの開発者によって GitHub に既に公開されているリリースファイルの発見とダウンロードを支援するだけです。 これらのダウンロードのコンテンツ、安全性、動作はそれぞれの作者と配布者の責任であり、このプロジェクトとは無関係です。 GitHub Store を使用することで、ダウンロードしたソフトウェアのインストールと実行は自己責任で行うことを理解し同意したものとみなされます。 このプロジェクトは、いかなるインストーラーが安全であること、マルウェアを含まないこと、または特定の目的に適していることを審査、検証、保証するものではありません。 --- ## スター履歴 Star History Chart ![Alt](https://repobeats.axiom.co/api/embed/20367dca127572e9c47db33850979d78df2c6a8b.svg "Repobeats analytics image") ## 📄 ライセンス GitHub Store は **Apache License, Version 2.0** の下でリリースされます。 ``` Copyright 2025 rainxchzed Licensed under the Apache License, Version 2.0 (the "License"); you may not use this project 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: docs/README-KR.md ================================================

# GitHub Store

API Kotlin Compose Multiplatform material





OpenHub-Store%2FGitHub-Store | Trendshift Featured|HelloGitHub

# 🗺️ 프로젝트 개요 GitHub Store는 오픈소스 소프트웨어의 발견과 설치를 간편하게 만들기 위해 설계된 GitHub 릴리즈 전용 크로스플랫폼 앱 스토어입니다. 설치 가능한 바이너리(APK, EXE, DMG, AppImage, DEB, RPM)를 자동으로 감지하고, 원클릭 설치를 제공하며, 업데이트를 추적하고, 리포지터리 정보를 깔끔한 앱 스토어 스타일 인터페이스로 제공합니다. Android 및 Desktop 플랫폼을 위해 Kotlin Multiplatform과 Compose Multiplatform으로 개발되었습니다.
> [!CAUTION] > 자유롭고 오픈소스인 Android가 위협받고 있습니다. Google은 Android를 폐쇄적인 플랫폼으로 바꿔 원하는 앱을 설치할 수 있는 기본적인 자유를 제한하려 합니다. 목소리를 높여주세요 – [keepandroidopen.org](https://keepandroidopen.org/).

# 📔 위키 및 리소스 FAQ와 유용한 정보는 GitHub Store [위키](https://github.com/OpenHub-Store/GitHub-Store/wiki)를 확인하세요 🌐 **웹사이트:** [github-store.org](https://github-store.org) 💬 **Discord:** [커뮤니티 참여하기](https://discord.gg/x9Cvh2Z9qS) 📜 **개인정보처리방침:** [github-store.org/privacy-policy](https://github-store.org/privacy-policy/)
---
### 📋 법적 고지 GitHub Store는 GitHub, Inc.와 무관한 독립적인 오픈소스 프로젝트입니다. 이 이름은 앱의 기능(GitHub 릴리즈 발견)을 설명하는 것으로, 상표권 소유를 주장하는 것이 아닙니다. GitHub®는 GitHub, Inc.의 등록 상표입니다.
---

# 🔃 다운로드

Get it on Obtainium Get it on GitHub Store

> [!IMPORTANT] > **macOS 사용자:** Apple이 GitHub Store를 확인할 수 없다는 경고가 표시될 수 있습니다. 이는 앱이 App Store 외부에서 배포되고 아직 공증되지 않았기 때문입니다. 시스템 설정 → 개인 정보 보호 및 보안 → 그래도 열기를 통해 허용하세요. ---

# 🏆 미디어 소개

Featured by HowToMen
HowToMen: 2026년 최고의 Android 앱 TOP 20 | Google Play 스토어보다 나은 앱 스토어 TOP 12
HelloGitHub: 추천 프로젝트

--- ## 🚀 기능 - **스마트 발견** - 시간 기반 필터가 있는 "Trending", "Hot Release", "Most Popular" 프로젝트 홈 섹션. - 유효한 설치 가능 파일이 있는 리포지터리만 표시됩니다. - Android/데스크톱 사용자가 관련 앱을 먼저 볼 수 있도록 플랫폼을 고려한 토픽 점수 산정. - 향상된 관련성 순위와 성능을 갖춘 새로워진 검색. - **릴리즈 브라우저 및 설치** - 최신 버전뿐만 아니라 모든 릴리즈에서 탐색하고 설치할 수 있는 릴리즈 선택기. - 각 리포지터리의 모든 릴리즈를 가져옵니다. - 단일 "최신 버전 설치" 액션과 사용 가능한 모든 릴리즈 및 설치 프로그램의 확장 가능한 목록. - 자동 호환성 확인이 있는 수동 설치 옵션. - **풍부한 상세 화면** - 앱 이름, 버전, 공유 액션. - 스타 수, 포크 수, 열린 이슈. - 렌더링된 README 콘텐츠("이 앱 소개"). - 선택한 릴리즈의 Markdown 형식 릴리즈 노트. - 플랫폼 레이블과 파일 크기가 있는 설치 프로그램 목록. - 딥 링크 지원 — URL을 통해 리포지터리 상세 정보를 직접 열기. - 개발자의 리포지터리와 활동을 탐색하는 개발자 프로필 화면. - **앱 관리** - GitHub Store에서 직접 설치된 앱을 열고, 제거하고, 다운그레이드. - Android: APK 아키텍처 매칭(armv7/armv8), 패키지 모니터링, 업데이트 추적. - Desktop(Windows/macOS/Linux): 설치 프로그램을 사용자의 다운로드 폴더에 다운로드하고 기본 핸들러로 열기. - **즐겨찾기 리포지터리** - 앱 내에서 GitHub 즐겨찾기 리포지터리를 저장하고 탐색. - **네트워크 및 성능** - 설정 가능한 네트워크 라우팅을 위한 동적 프록시 지원. - 더 빠른 로딩과 API 사용량 감소를 위한 향상된 캐싱 시스템. --- ## 🔍 내 앱이 GitHub Store에 표시되려면? GitHub Store는 비공개 인덱싱이나 수동 큐레이션 규칙을 전혀 사용하지 않습니다. 다음 조건을 충족하면 프로젝트가 자동으로 표시될 수 있습니다: 1. **GitHub의 공개 리포지터리** - 가시성이 `public`으로 설정되어야 합니다. 2. **최신 릴리즈의 설치 가능한 파일** - 최신 릴리즈에 지원되는 확장자를 가진 파일이 하나 이상 포함되어야 합니다: - Android: `.apk` - Windows: `.exe`, `.msi` - macOS: `.dmg`, `.pkg` - Linux: `.deb`, `.rpm`, `.AppImage` - GitHub Store는 GitHub의 자동 생성 소스 코드 아카이브(`Source code (zip)` / `Source code (tar.gz)`)를 무시합니다. 3. **검색 / 토픽을 통한 발견 가능성** - 리포지터리는 GitHub의 공개 검색 API를 통해 가져옵니다. - 토픽, 언어, 설명이 순위에 영향을 줍니다: - Android 앱: `android`, `mobile`, `apk` 같은 토픽. - 데스크톱 앱: `desktop`, `windows`, `linux`, `macos`, `compose-desktop`, `electron` 같은 토픽. - 스타가 몇 개라도 있으면 Trending/Hot Release/Most Popular 섹션에 표시될 가능성이 높아집니다. 리포지터리가 이 조건을 충족하면 GitHub Store가 검색을 통해 자동으로 찾아 표시할 수 있습니다 — 수동 제출이 필요 없습니다. --- ## ✅ 장점 / GitHub Store를 사용해야 하는 이유 - **GitHub 릴리즈를 일일이 찾아다닐 필요 없음** 내 플랫폼용 바이너리를 실제로 제공하는 리포지터리만 확인하세요. - **설치된 앱을 파악하고 있음** GitHub Store를 통해 설치된 앱(Android)을 추적하고 새 릴리즈가 나오면 알려줘서 GitHub에서 다시 검색하지 않아도 업데이트할 수 있습니다. - **항상 최신 상태** 설치는 기본적으로 가장 최근에 게시된 릴리즈를 사용하며, 릴리즈 선택기를 통해 이전 릴리즈도 탐색·설치할 수 있습니다. - **오픈소스이며 확장 가능** 네트워크, 도메인 로직, UI 사이에 명확한 분리가 있는 KMP로 작성 — 포크, 확장, 적용이 쉽습니다. --- ## 🔐 GitHub Store APK 서명 인증서 모든 공식 GitHub Store 릴리즈는 다음 인증서 지문으로 서명되어 있습니다: SHA-256: `B7:F2:8E:19:8E:48:C1:93:B0:38:C6:5D:92:DD:F7:BC:07:7B:0D:B5:9E:BC:9B:25:0A:6D:AC:48:C1:18:03:CA` --- ## 🔑 GitHub OAuth 설정 **요약** 1. GitHub OAuth App 생성 2. **Client ID** 복사 3. `local.properties`에 입력
전체 설정 가이드 보기
### 1 - GitHub OAuth App 생성 다음으로 이동: **GitHub → Settings → Developer settings → OAuth Apps → New OAuth App** | 필드 | 값 | | ------------------------------ | ----------------------------------------------- | | **Application name** | 원하는 이름(예: *GitHub Store Dev*) | | **Homepage URL** | `https://github.com/username/repo_name` | | **Authorization callback URL** | `githubstore://callback` | 그런 다음 **Create application**을 클릭합니다. ### 2 - Client ID 복사 앱 생성 후 GitHub는 다음을 표시합니다: - **Client ID** ← 필요한 것 - **Client Secret** ← ❗ 이 프로젝트에는 필요 없음 ### 3 - 프로젝트에 추가 프로젝트의 `local.properties` 파일(프로젝트 루트)을 열고 다음을 추가: ```properties GITHUB_CLIENT_ID=YOUR_CLIENT_ID_HERE ``` ### 4 - 동기화 및 실행 프로젝트를 동기화하고 앱을 실행합니다. 이제 GitHub로 로그인할 수 있습니다. ### ❗ 중요 사항 - `local.properties`는 **Git에 커밋되지 않아** Client ID가 로컬에 유지됩니다. - 이 프로젝트는 **Client ID**만 필요합니다(Client Secret 불필요). - 각 개발자는 개발을 위해 자체 OAuth App을 만들어야 합니다.
--- ## ☕ 프로젝트 지원하기 GitHub Store는 고등학생이 개발하고 유지관리하고 있습니다. 여러분의 지원이 그에게 다음을 가능하게 합니다: ✅ **앱을 버그 없이 유지** — 이슈에 대응하고 수정 사항을 빠르게 배포 ✅ **커뮤니티 요청 기능 추가** — 사용자가 실제로 필요로 하는 것을 구현 ### 💖 지원 방법 Buy Me a Coffee GitHub Sponsors **지금 후원하기 어렵다면?** 괜찮습니다! 다음과 같은 방법으로도 도울 수 있습니다: - ⭐ **이 리포지터리에 스타 달기** — 다른 사람들이 GitHub Store를 발견하는 데 도움이 됩니다 - 🐛 **버그 신고** — 모든 사람을 위한 앱 개선에 기여합니다 - 📢 **친구에게 공유** — 다른 개발자와 지인들에게 알려주세요! - 💬 **[Discord](https://discord.gg/x9Cvh2Z9qS) 참여** — 여러분의 피드백이 로드맵을 형성합니다 금전적이든 아니든 모든 형태의 지원이 큰 의미를 가지며 이 프로젝트를 살아있게 합니다. 감사합니다! --- ## ⚠️ 면책 조항 GitHub Store는 서드파티 개발자가 GitHub에 이미 게시한 릴리즈 파일을 발견하고 다운로드하는 것을 돕는 역할만 합니다. 해당 다운로드의 내용, 안전성, 동작은 이 프로젝트가 아닌 각 제작자 및 배포자의 책임입니다. GitHub Store를 사용함으로써 다운로드한 소프트웨어의 설치 및 실행이 전적으로 자신의 책임임을 이해하고 동의합니다. 이 프로젝트는 어떠한 설치 프로그램이 안전하거나, 악성 코드가 없거나, 특정 목적에 적합하다는 것을 검토, 검증 또는 보장하지 않습니다. --- ## 스타 히스토리 Star History Chart ![Alt](https://repobeats.axiom.co/api/embed/20367dca127572e9c47db33850979d78df2c6a8b.svg "Repobeats analytics image") ## 📄 라이선스 GitHub Store는 **Apache License, Version 2.0** 하에 출시됩니다. ``` Copyright 2025 rainxchzed Licensed under the Apache License, Version 2.0 (the "License"); you may not use this project 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: docs/README-PL.md ================================================

# GitHub Store

API Kotlin Compose Multiplatform material





OpenHub-Store%2FGitHub-Store | Trendshift Featured|HelloGitHub

English | Español | Français | Italiano | Русский | Polski | Türkçe | 中文 | 日本語 | 한국어 | বাংলা | हिन्दी

# 🗺️ Przegląd projektu GitHub Store to wieloplatformowy sklep z aplikacjami dla wydań z GitHuba, zaprojektowany w celu uproszczenia odkrywania i instalowania oprogramowania open source. Automatycznie wykrywa instalowalne pliki binarne (APK, EXE, DMG, AppImage, DEB, RPM), oferuje instalację jednym kliknięciem, śledzi aktualizacje i prezentuje informacje o repozytoriach w przejrzystym interfejsie w stylu sklepu z aplikacjami. Zbudowany z użyciem Kotlin Multiplatform i Compose Multiplatform na platformy Android i Desktop.
> [!CAUTION] > Wolny i otwartoźródłowy Android jest zagrożony. Google zamieni Androida w zamkniętą platformę, ograniczając Twoją podstawową wolność instalowania wybranych aplikacji. Wyraź swoje zdanie – [keepandroidopen.org](https://keepandroidopen.org/).

# 📔 Wiki i zasoby Sprawdź [Wiki](https://github.com/OpenHub-Store/GitHub-Store/wiki) GitHub Store, aby znaleźć odpowiedzi na najczęściej zadawane pytania i przydatne informacje 🌐 **Strona internetowa:** [github-store.org](https://github-store.org) 💬 **Discord:** [Dołącz do społeczności](https://discord.gg/x9Cvh2Z9qS) 📜 **Polityka prywatności:** [github-store.org/privacy-policy](https://github-store.org/privacy-policy/)
---
### 📋 Informacja prawna GitHub Store jest niezależnym projektem open source, niezwiązanym z GitHub, Inc. Nazwa opisuje funkcjonalność aplikacji (odkrywanie wydań z GitHuba) i nie sugeruje własności znaku towarowego. GitHub® jest zarejestrowanym znakiem towarowym GitHub, Inc.
---
# 🔃 Pobierz

Get it on Obtainium

Join Discord

> [!IMPORTANT] > **Użytkownicy macOS:** Możesz zobaczyć ostrzeżenie, że Apple nie może zweryfikować GitHub Store. Dzieje się tak, ponieważ aplikacja jest dystrybuowana poza App Store i nie jest jeszcze notaryzowana. Zezwól na nią w Ustawienia systemowe → Prywatność i bezpieczeństwo → Otwórz mimo to. ---
# 🏆 Wyróżniony w

Wyróżniony przez HowToMen
HowToMen: Top 20 Najlepszych Aplikacji na Androida 2026 | Top 12 Sklepów z Aplikacjami Lepszych niż Google Play Store
HelloGitHub: Wyróżniony Projekt

--- ## 🚀 Funkcje - **Inteligentne odkrywanie** - Sekcje na stronie głównej dla projektów "Trending", "Hot Release" i "Most Popular" z filtrami czasowymi. - Wyświetlane są tylko repozytoria z poprawnymi plikami instalacyjnymi. - Ocena tematów uwzględniająca platformę, aby użytkownicy Androida/komputera widzieli najpierw odpowiednie aplikacje. - Odnowione wyszukiwanie z lepszym rankingiem trafności i wydajnością. - **Przeglądarka wydań i instalacja** - Selektor wydań umożliwiający przeglądanie i instalację z dowolnego wydania, nie tylko najnowszego. - Pobiera wszystkie wydania każdego repozytorium. - Pojedyncza akcja "Zainstaluj najnowszą wersję" oraz rozwijana lista wszystkich dostępnych wydań i ich instalatorów. - Opcja ręcznej instalacji z automatycznym sprawdzaniem kompatybilności. - **Szczegółowy ekran informacji** - Nazwa aplikacji, wersja, przycisk "Zainstaluj najnowszą wersję" oraz akcja udostępniania. - Gwiazdki, forki, otwarte zgłoszenia. - Wyrenderowana zawartość README ("O tej aplikacji"). - Notatki do wydania w formacie Markdown dla dowolnego wybranego wydania. - Lista instalatorów z etykietami platform i rozmiarami plików. - Obsługa głębokich linków — otwiera szczegóły repozytorium bezpośrednio przez URL. - Ekran profilu dewelopera do przeglądania repozytoriów i aktywności dewelopera. - **Zarządzanie aplikacjami** - Otwieraj, odinstalowuj i instaluj starsze wersje aplikacji bezpośrednio z GitHub Store. - Android: dopasowywanie architektury APK (armv7/armv8), monitorowanie pakietów i śledzenie aktualizacji. - Komputer (Windows/macOS/Linux): pobiera instalatory do folderu Pobrane użytkownika i otwiera je domyślnym programem obsługi. - **Ulubione repozytoria** - Zapisuj i przeglądaj swoje ulubione repozytoria z GitHuba bezpośrednio w aplikacji. - **Sieć i wydajność** - Dynamiczna obsługa proxy do konfigurowalnego routingu sieciowego. - Ulepszony system pamięci podręcznej zapewniający szybsze ładowanie i mniejsze zużycie API. - **Wieloplatformowy UX** - Android: natywny ekran powitalny, obsługa wygasania sesji i adaptacyjna ikona. - Komputer: priorytetowa obsługa AppImage na Linuxie wraz z formatami DEB i RPM. - Zlokalizowany w 12 językach: angielski, hiszpański, francuski, japoński, koreański, polski, rosyjski, chiński, bengalski, hindi, włoski i turecki. --- ## 🔍 Jak moja aplikacja pojawia się w GitHub Store? GitHub Store nie korzysta z żadnego prywatnego indeksowania ani ręcznych zasad kuracji. Twój projekt może pojawić się automatycznie, jeśli spełnia następujące warunki: 1. **Publiczne repozytorium na GitHubie** - Widoczność musi być ustawiona na `public`. 2. **Pliki instalacyjne w najnowszym wydaniu** - Najnowsze wydanie musi zawierać co najmniej jeden plik z obsługiwanym rozszerzeniem: - Android: `.apk` - Windows: `.exe`, `.msi` - macOS: `.dmg`, `.pkg` - Linux: `.deb`, `.rpm`, `.AppImage` - GitHub Store ignoruje automatycznie generowane archiwa kodu źródłowego (`Source code (zip)` / `Source code (tar.gz)`). 3. **Wykrywalność przez wyszukiwanie / tematy** - Repozytoria są pobierane za pośrednictwem publicznego API wyszukiwania GitHuba. - Tematy, język i opis pomagają w klasyfikacji: - Aplikacje na Androida: tematy takie jak `android`, `mobile`, `apk`. - Aplikacje desktopowe: tematy takie jak `desktop`, `windows`, `linux`, `macos`, `compose-desktop`, `electron`. - Posiadanie przynajmniej kilku gwiazdek zwiększa prawdopodobieństwo pojawienia się w sekcjach Trending/Hot Release/Most Popular. Jeśli Twoje repozytorium spełnia te warunki, GitHub Store może je znaleźć przez wyszukiwanie i wyświetlić automatycznie, bez konieczności ręcznego zgłaszania. --- ## 🧭 Jak działa GitHub Store (przegląd) 1. **Wyszukiwanie** - Używa endpointu `/search/repositories` GitHuba z zapytaniami dostosowanymi do platformy. - Stosuje prostą ocenę opartą na tematach, języku i opisie. - Filtruje zarchiwizowane repozytoria oraz te z małą liczbą sygnałów. 2. **Sprawdzanie wydań i plików** - Dla repozytoriów-kandydatów wywołuje `/repos/{owner}/{repo}/releases/latest`. - Sprawdza tablicę `assets` pod kątem rozszerzeń plików specyficznych dla platformy. - Jeśli nie znaleziono odpowiedniego pliku, repozytorium jest wykluczane z wyników. - Użytkownicy mogą również przeglądać wszystkie wydania za pomocą selektora wydań. 3. **Ekran szczegółów** - Informacje o repozytorium: nazwa, właściciel, opis, gwiazdki, forki, zgłoszenia. - Przeglądarka wydań: nawiguj po dowolnym wydaniu z jego tagiem, datą, changelogiem i plikami. - README: ładowany z głównej gałęzi i renderowany jako "O tej aplikacji". - Link do profilu dewelopera i akcja udostępniania. - Dostępny przez głębokie linki do bezpośredniej nawigacji. 4. **Proces instalacji** - Gdy użytkownik kliknie "Zainstaluj najnowszą wersję" lub wybierze konkretne wydanie: - Wybiera najbardziej odpowiedni plik dla bieżącej platformy (z dopasowaniem architektury na Androidzie). - Przesyła strumieniowo pobieranie z obsługą pamięci podręcznej. - Deleguje do instalatora systemu operacyjnego (instalator APK na Androidzie, domyślny program obsługi na komputerze). - Na Androidzie rejestruje instalację w lokalnej bazie danych i używa monitorowania pakietów, aby utrzymać listę zainstalowanych aplikacji w synchronizacji. - Obsługuje akcje otwierania, odinstalowywania i instalowania starszych wersji zarządzanych aplikacji. --- ## ✅ Zalety / Dlaczego warto używać GitHub Store? - **Koniec z przeszukiwaniem wydań na GitHubie** Zobacz tylko repozytoria, które faktycznie dystrybuują pliki binarne dla Twojej platformy. - **Wie, co zainstalowałeś** Śledzi aplikacje zainstalowane przez GitHub Store (Android) i podkreśla, gdy dostępne są nowe wydania, abyś mógł je zaktualizować bez ponownego przeszukiwania GitHuba. - **Zawsze aktualne** Instalacje domyślnie używają najnowszego opublikowanego wydania, z opcją przeglądania i instalowania z dowolnego wcześniejszego wydania za pomocą selektora wydań. - **Spójne doświadczenie na wszystkich platformach** Ten sam interfejs i logika dla Androida i komputera, z natywnym dla platformy zachowaniem instalacyjnym. - **Open source i rozszerzalny** Napisany w KMP z jasnym rozdziałem między siecią, logiką domenową i interfejsem użytkownika — łatwy do sforkowania, rozszerzenia lub dostosowania. --- ## 🔐 Certyfikat podpisu APK GitHub Store Wszystkie oficjalne wydania GitHub Store są podpisane następującym odciskiem certyfikatu: SHA-256: `B7:F2:8E:19:8E:48:C1:93:B0:38:C6:5D:92:DD:F7:BC:07:7B:0D:B5:9E:BC:9B:25:0A:6D:AC:48:C1:18:03:CA` --- ## 🔑 Konfiguracja GitHub OAuth **Podsumowanie** 1. Utwórz aplikację GitHub OAuth 2. Skopiuj **Client ID** 3. Wklej go do `local.properties`
Pokaż pełną instrukcję konfiguracji
### 1 - Utwórz aplikację GitHub OAuth Przejdź do: **GitHub → Settings → Developer settings → OAuth Apps → New OAuth App** | Pole | Wartość | | ------------------------------ | ------------------------------------------- | | **Application name** | Dowolna nazwa (np. *GitHub Store Dev*) | | **Homepage URL** | `https://github.com/username/repo_name` | | **Authorization callback URL** | `githubstore://callback` | Następnie kliknij **Create application**. ### 2 - Skopiuj swój Client ID Po utworzeniu aplikacji GitHub wyświetli: - **Client ID** ← tego potrzebujesz - **Client Secret** ← ❗ NIE jest wymagany w tym projekcie ### 3 - Dodaj go do projektu Otwórz plik `local.properties` w swoim projekcie (katalog główny projektu) i dodaj: ```properties GITHUB_CLIENT_ID=YOUR_CLIENT_ID_HERE ``` ### 4 - Zsynchronizuj i uruchom Zsynchronizuj projekt i uruchom aplikację. Teraz powinieneś móc zalogować się przez GitHub. ### ❗ Ważne uwagi - `local.properties` **nie jest commitowany do Gita**, więc Twój Client ID pozostaje lokalny. - Ten projekt wymaga jedynie **Client ID** (nie Client Secret). - Każdy deweloper powinien utworzyć własną aplikację OAuth na potrzeby rozwoju.
--- ## ☕ Wesprzyj projekt **GitHub Store** osiągnął **ponad 48 000 aktywnych użytkowników** i **ponad 5 500 gwiazdek na GitHubie** — i jest **w 100% darmowy**, bez reklam, bez śledzenia i bez funkcji premium. Buduję go i utrzymuję całkowicie samodzielnie, kończąc jednocześnie szkołę średnią. Twoje wsparcie (nawet 3$) pomaga mi: ✅ **Utrzymywać aplikację wolną od błędów** — odpowiadać na zgłoszenia i szybko wysyłać poprawki ✅ **Dodawać funkcje zgłaszane przez społeczność** — implementować to, czego użytkownicy naprawdę potrzebują ✅ **Utrzymywać infrastrukturę** — serwery, API i koszty wdrożenia ### 💖 Sposoby wsparcia Buy Me a Coffee GitHub Sponsors **Nie możesz teraz wesprzeć finansowo?** Nic nie szkodzi! Możesz też pomóc: - ⭐ **Dając gwiazdkę temu repozytorium** — pomaga innym odkryć GitHub Store - 🐛 **Zgłaszając błędy** — ulepszasz aplikację dla wszystkich - 📢 **Udostępniając znajomym** — rozpowszechniaj informacje wśród innych deweloperów - 💬 **Dołączając do naszego [Discorda](https://discord.gg/x9Cvh2Z9qS)** — Twoje opinie kształtują plan rozwoju Każda forma wsparcia — finansowa lub nie — wiele znaczy i utrzymuje ten projekt przy życiu. Dziękuję! --- ## ⚠️ Zastrzeżenie GitHub Store jedynie pomaga odkrywać i pobierać pliki wydań, które są już opublikowane na GitHubie przez zewnętrznych deweloperów. Zawartość, bezpieczeństwo i zachowanie tych pobrań leżą wyłącznie w gestii ich odpowiednich autorów i dystrybutorów, a nie tego projektu. Korzystając z GitHub Store, rozumiesz i akceptujesz, że instalujesz i uruchamiasz jakiekolwiek pobrane oprogramowanie na własne ryzyko. Ten projekt nie sprawdza, nie weryfikuje ani nie gwarantuje, że jakikolwiek instalator jest bezpieczny, wolny od złośliwego oprogramowania lub odpowiedni do jakiegokolwiek konkretnego celu. --- ## Historia gwiazdek Star History Chart ![Alt](https://repobeats.axiom.co/api/embed/20367dca127572e9c47db33850979d78df2c6a8b.svg "Repobeats analytics image") ## 📄 Licencja GitHub Store jest dystrybuowany na warunkach **Licencji Apache, wersja 2.0**. ``` Copyright 2025 rainxchzed Licensed under the Apache License, Version 2.0 (the "License"); you may not use this project 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: docs/README-RU.md ================================================

# GitHub Store

API Kotlin Compose Multiplatform material





OpenHub-Store%2FGitHub-Store | Trendshift Featured|HelloGitHub

# 🗺️ Обзор проекта GitHub Store — это кросс-платформенный магазин приложений для релизов GitHub, созданный для упрощения поиска и установки программного обеспечения с открытым исходным кодом. Он автоматически обнаруживает устанавливаемые бинарные файлы (APK, EXE, DMG, AppImage, DEB, RPM), предлагает установку в один клик, отслеживает обновления и представляет информацию о репозиториях в чистом интерфейсе в стиле магазина приложений. Создан на базе Kotlin Multiplatform и Compose Multiplatform для платформ Android и Desktop.
> [!CAUTION] > Свободный и открытый Android под угрозой. Google превратит Android в закрытую платформу, ограничивая вашу основную свободу устанавливать приложения по своему выбору. Заявите о своей позиции – [keepandroidopen.org](https://keepandroidopen.org/).

# 📔 Вики и ресурсы Обратитесь к [Вики](https://github.com/OpenHub-Store/GitHub-Store/wiki) GitHub Store для часто задаваемых вопросов и полезной информации 🌐 **Веб-сайт:** [github-store.org](https://github-store.org) 💬 **Discord:** [Присоединяйтесь к сообществу](https://discord.gg/x9Cvh2Z9qS) 📜 **Политика конфиденциальности:** [github-store.org/privacy-policy](https://github-store.org/privacy-policy/)
---
### 📋 Правовая информация GitHub Store — это независимый проект с открытым исходным кодом, не связанный с GitHub, Inc. Название описывает функциональность приложения (обнаружение релизов GitHub) и не подразумевает владения товарным знаком. GitHub® является зарегистрированным товарным знаком GitHub, Inc.
---

# 🔃 Скачать

Get it on Obtainium Get it on GitHub Store

> [!IMPORTANT] > **Пользователи macOS:** Вы можете увидеть предупреждение о том, что Apple не может проверить GitHub Store. Это происходит потому, что приложение распространяется за пределами App Store и ещё не нотаризовано. Разрешите его через Системные настройки → Конфиденциальность и безопасность → Всё равно открыть. ---

# 🏆 Упоминания в СМИ

Featured by HowToMen
HowToMen: Top 20 лучших приложений для Android 2026 | Top 12 магазинов приложений лучше Google Play Store
HelloGitHub: Избранный проект

--- ## 🚀 Возможности - **Умное обнаружение** - Разделы на главном экране для проектов "Trending", "Hot Release" и "Most Popular" с фильтрами по времени. - Отображаются только репозитории с действительными устанавливаемыми файлами. - Оценка тем с учётом платформы, чтобы пользователи Android/десктопа видели релевантные приложения первыми. - Обновлённый поиск с улучшенным ранжированием по релевантности и производительности. - **Браузер релизов и установка** - Селектор релизов для просмотра и установки из любого релиза, а не только последнего. - Получает все релизы каждого репозитория. - Действие «Установить последнюю версию» в один клик, а также выпадающий список всех доступных релизов и их установщиков. - Возможность ручной установки с автоматическими проверками совместимости. - **Подробный экран деталей** - Название приложения, версия и кнопка «Поделиться». - Звёзды, форки, открытые issues. - Отрендеренное содержимое README («О приложении»). - Примечания к релизу в формате Markdown для любого выбранного релиза. - Список установщиков с метками платформ и размерами файлов. - Поддержка глубоких ссылок — открывайте детали репозитория напрямую через URL. - Экран профиля разработчика для просмотра репозиториев и активности разработчика. - **Управление приложениями** - Открывайте, удаляйте и откатывайте версии установленных приложений прямо из GitHub Store. - Android: совпадение архитектуры APK (armv7/armv8), мониторинг пакетов и отслеживание обновлений. - Десктоп (Windows/macOS/Linux): загрузка установщиков в папку «Загрузки» пользователя и открытие с помощью обработчика по умолчанию. - **Избранные репозитории** - Сохраняйте и просматривайте ваши избранные репозитории GitHub из приложения. - **Сеть и производительность** - Поддержка динамического прокси для настраиваемой маршрутизации сети. - Улучшенная система кэширования для более быстрой загрузки и снижения использования API. --- ## 🔍 Как моё приложение появится в GitHub Store? GitHub Store не использует никакой частной индексации или ручных правил курирования. Ваш проект может появиться автоматически, если он соответствует следующим условиям: 1. **Публичный репозиторий на GitHub** - Видимость должна быть `public`. 2. **Устанавливаемые файлы в последнем релизе** - Последний релиз должен содержать как минимум один файл с совместимым расширением: - Android: `.apk` - Windows: `.exe`, `.msi` - macOS: `.dmg`, `.pkg` - Linux: `.deb`, `.rpm`, `.AppImage` - GitHub Store игнорирует автоматически сгенерированные архивы исходного кода (`Source code (zip)` / `Source code (tar.gz)`). 3. **Обнаружимость через поиск / topics** - Репозитории получаются через публичный API поиска GitHub. - Topics, язык и описание помогают в ранжировании: - Приложения для Android: topics вроде `android`, `mobile`, `apk`. - Десктопные приложения: topics вроде `desktop`, `windows`, `linux`, `macos`, `compose-desktop`, `electron`. - Наличие хотя бы нескольких звёзд увеличивает вероятность появления в разделах Trending/Hot Release/Most Popular. Если ваш репозиторий соответствует этим условиям, GitHub Store может найти его через поиск и отобразить автоматически, без необходимости ручной подачи заявки. --- ## ✅ Преимущества / Зачем использовать GitHub Store? - **Больше не нужно копаться в релизах GitHub** Вы видите только репозитории, которые действительно распространяют бинарные файлы для вашей платформы. - **Знает, что вы установили** Отслеживает приложения, установленные через GitHub Store (Android), и уведомляет о наличии новых релизов, чтобы вы могли обновиться без повторного поиска на GitHub. - **Всегда актуально** Установки по умолчанию используют последний опубликованный релиз, с возможностью просмотра и установки из любого предыдущего релиза через селектор релизов. - **Открытый исходный код и расширяемость** Написан на KMP с чётким разделением сети, доменной логики и пользовательского интерфейса — легко форкнуть, расширить или адаптировать. --- ## 🔐 Сертификат подписи APK GitHub Store Все официальные релизы GitHub Store подписаны следующим отпечатком сертификата: SHA-256: `B7:F2:8E:19:8E:48:C1:93:B0:38:C6:5D:92:DD:F7:BC:07:7B:0D:B5:9E:BC:9B:25:0A:6D:AC:48:C1:18:03:CA` --- ## 🔑 Настройка GitHub OAuth **Краткое описание** 1. Создайте GitHub OAuth App 2. Скопируйте **Client ID** 3. Добавьте его в `local.properties`
Показать полное руководство по настройке
### 1 - Создать GitHub OAuth App Перейдите в: **GitHub → Settings → Developer settings → OAuth Apps → New OAuth App** | Поле | Значение | | ------------------------------ | ------------------------------------------- | | **Application name** | Любое на ваш выбор (напр. *GitHub Store Dev*) | | **Homepage URL** | `https://github.com/username/repo_name` | | **Authorization callback URL** | `githubstore://callback` | Затем нажмите **Create application**. ### 2 - Скопировать Client ID После создания приложения GitHub покажет: - **Client ID** ← это то, что вам нужно - **Client Secret** ← ❗ НЕ требуется для этого проекта ### 3 - Добавить в проект Откройте файл `local.properties` вашего проекта (корень проекта) и добавьте: ```properties GITHUB_CLIENT_ID=YOUR_CLIENT_ID_HERE ``` ### 4 - Синхронизировать и запустить Синхронизируйте проект и запустите приложение. Теперь вы сможете войти через GitHub. ### ❗ Важные замечания - `local.properties` **не загружается в Git**, поэтому ваш Client ID остаётся локальным. - Этот проект требует только **Client ID** (не Client Secret). - Каждый разработчик должен создать собственное OAuth-приложение для разработки.
--- ## ☕ Поддержать проект GitHub Store создан и поддерживается старшеклассником. Ваша поддержка помогает ему: ✅ **Поддерживать приложение без ошибок** — отвечать на issues и быстро выпускать исправления ✅ **Добавлять функции по запросам сообщества** — реализовывать то, что действительно нужно пользователям ### 💖 Способы поддержки Buy Me a Coffee GitHub Sponsors **Не можете поддержать финансово прямо сейчас?** Ничего страшного! Вы также можете помочь: - ⭐ **Поставив звезду этому репозиторию** — помогает другим открыть для себя GitHub Store - 🐛 **Сообщая об ошибках** — улучшает приложение для всех - 📢 **Поделившись с друзьями** — расскажите другим разработчикам и знакомым! - 💬 **Присоединившись к нашему [Discord](https://discord.gg/x9Cvh2Z9qS)** — ваши отзывы формируют план развития Любая форма поддержки — финансовая или нет — значит многое и помогает проекту жить. Спасибо! --- ## ⚠️ Отказ от ответственности GitHub Store лишь помогает вам находить и скачивать файлы релизов, которые уже опубликованы на GitHub сторонними разработчиками. Содержание, безопасность и поведение этих загрузок являются исключительной ответственностью их авторов и распространителей, а не данного проекта. Используя GitHub Store, вы понимаете и соглашаетесь с тем, что устанавливаете и запускаете любое загруженное программное обеспечение на свой страх и риск. Данный проект не проверяет, не подтверждает и не гарантирует, что какой-либо установщик является безопасным, свободным от вредоносного ПО или подходящим для какой-либо конкретной цели. --- ## История звёзд Star History Chart ![Alt](https://repobeats.axiom.co/api/embed/20367dca127572e9c47db33850979d78df2c6a8b.svg "Repobeats analytics image") ## 📄 Лицензия GitHub Store распространяется под **Лицензией Apache, Версия 2.0**. ``` Copyright 2025 rainxchzed Licensed under the Apache License, Version 2.0 (the "License"); you may not use this project 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: docs/README-TR.md ================================================

# GitHub Store

API Kotlin Compose Multiplatform material





OpenHub-Store%2FGitHub-Store | Trendshift Featured|HelloGitHub

# 🗺️ Projeye Genel Bakış GitHub Store, açık kaynaklı yazılımları keşfetmeyi ve yüklemeyi kolaylaştırmak için tasarlanmış, GitHub sürümleri için çok platformlu bir uygulama mağazasıdır. Kurulabilir ikili dosyaları (APK, EXE, DMG, AppImage, DEB, RPM) otomatik olarak algılar, tek tıkla kurulum sunar, güncellemeleri takip eder ve depo bilgilerini temiz bir uygulama mağazası tarzı arayüzde sunar. Android ve Desktop platformları için Kotlin Multiplatform ve Compose Multiplatform ile geliştirilmiştir.
> [!CAUTION] > Özgür ve Açık Kaynaklı Android tehdit altında. Google, Android'i kapalı bir platforma dönüştürerek istediğiniz uygulamaları yükleme özgürlüğünüzü kısıtlayacak. Sesinizi duyurun – [keepandroidopen.org](https://keepandroidopen.org/).

# 📔 Wiki ve Kaynaklar SSS ve yararlı bilgiler için GitHub Store [Wiki](https://github.com/OpenHub-Store/GitHub-Store/wiki) sayfasına göz atın 🌐 **Web sitesi:** [github-store.org](https://github-store.org) 💬 **Discord:** [Topluluğa katılın](https://discord.gg/x9Cvh2Z9qS) 📜 **Gizlilik Politikası:** [github-store.org/privacy-policy](https://github-store.org/privacy-policy/)
---
### 📋 Yasal Uyarı GitHub Store, GitHub, Inc. ile bağlantısı olmayan bağımsız bir açık kaynak projesidir. İsim, uygulamanın işlevselliğini (GitHub sürümlerini keşfetme) tanımlamakta olup herhangi bir marka sahipliği iddiası taşımamaktadır. GitHub®, GitHub, Inc.'in tescilli markasıdır.
---

# 🔃 İndir

Get it on Obtainium Get it on GitHub Store

> [!IMPORTANT] > **macOS Kullanıcıları:** Apple'ın GitHub Store'u doğrulayamadığını belirten bir uyarı görebilirsiniz. Bu, uygulamanın App Store dışında dağıtılması ve henüz notarize edilmemiş olması nedeniyle gerçekleşir. Sistem Ayarları → Gizlilik ve Güvenlik → Yine de Aç yolunu izleyerek izin verin. ---

# 🏆 Öne Çıkarıldığı Yerler

Featured by HowToMen
HowToMen: 2026'nın En İyi 20 Android Uygulaması | Google Play Store'dan Daha İyi 12 Uygulama Mağazası
HelloGitHub: Öne Çıkan Proje

--- ## 🚀 Özellikler - **Akıllı keşif** - Zaman tabanlı filtrelerle "Trending", "Hot Release" ve "Most Popular" projeleri için ana sayfa bölümleri. - Yalnızca geçerli kurulabilir dosyalara sahip depolar gösterilir. - Android/masaüstü kullanıcılarının önce ilgili uygulamaları görmesi için platforma duyarlı konu puanlaması. - Geliştirilmiş alaka sıralaması ve performans ile yenilenmiş arama. - **Sürüm tarayıcı ve kurulumlar** - Yalnızca en son sürümden değil, herhangi bir sürümden göz atıp kurulum yapabileceğiniz sürüm seçici. - Her depo için tüm sürümleri getirir. - Tek "En son sürümü kur" eylemi ve tüm mevcut sürümlerin ve kurucularının genişletilebilir listesi. - Otomatik uyumluluk kontrolleriyle manuel kurulum seçeneği. - **Zengin ayrıntılar ekranı** - Uygulama adı, sürüm ve paylaşma eylemi. - Yıldızlar, fork'lar, açık sorunlar. - İşlenmiş README içeriği ("Bu uygulama hakkında"). - Seçilen herhangi bir sürüm için Markdown biçimlendirmeli sürüm notları. - Platform etiketleri ve dosya boyutlarıyla kurucuların listesi. - Derin bağlantı desteği — depo ayrıntılarını doğrudan URL üzerinden açın. - Bir geliştiricinin depolarını ve etkinliğini keşfetmek için geliştirici profil ekranı. - **Uygulama yönetimi** - Kurulu uygulamaları doğrudan GitHub Store üzerinden açın, kaldırın ve sürüm düşürün. - Android: APK mimari eşleştirme (armv7/armv8), paket izleme ve güncelleme takibi. - Masaüstü (Windows/macOS/Linux): kurucuları kullanıcının İndirilenler klasörüne indirir ve varsayılan işleyici ile açar. - **Yıldızlı depolar** - Uygulama içinden yıldızlı GitHub depolarınızı kaydedin ve göz atın. - **Ağ ve performans** - Yapılandırılabilir ağ yönlendirmesi için dinamik proxy desteği. - Daha hızlı yükleme ve daha az API kullanımı için geliştirilmiş önbellekleme sistemi. --- ## 🔍 Uygulamam GitHub Store'da nasıl görünür? GitHub Store herhangi bir özel indeksleme ya da manuel kürasyon kuralı kullanmaz. Projeniz şu koşulları sağlıyorsa otomatik olarak görünebilir: 1. **GitHub'da herkese açık depo** - Görünürlük `public` olarak ayarlanmış olmalıdır. 2. **En son sürümde kurulabilir dosyalar** - En son sürüm, desteklenen uzantıya sahip en az bir dosya içermelidir: - Android: `.apk` - Windows: `.exe`, `.msi` - macOS: `.dmg`, `.pkg` - Linux: `.deb`, `.rpm`, `.AppImage` - GitHub Store, GitHub'ın otomatik oluşturduğu kaynak arşivlerini (`Source code (zip)` / `Source code (tar.gz)`) yoksayar. 3. **Arama / konular aracılığıyla keşfedilebilir** - Depolar, GitHub'ın genel Arama API'si aracılığıyla getirilir. - Konular, dil ve açıklama sıralamaya yardımcı olur: - Android uygulamaları: `android`, `mobile`, `apk` gibi konular. - Masaüstü uygulamaları: `desktop`, `windows`, `linux`, `macos`, `compose-desktop`, `electron` gibi konular. - En az birkaç yıldıza sahip olmak, Trending/Hot Release/Most Popular bölümlerinde görünme olasılığını artırır. Deponuz bu koşulları sağlıyorsa GitHub Store onu arama yoluyla bulabilir ve otomatik olarak gösterebilir — manuel gönderim gerekmez. --- ## ✅ Artılar / GitHub Store neden kullanılır? - **GitHub sürümlerini artık elle aramak yok** Yalnızca platformunuz için gerçekten ikili dosya sunan depoları görün. - **Ne kurduğunuzu bilir** GitHub Store (Android) üzerinden kurulan uygulamaları takip eder ve yeni sürümler mevcut olduğunda vurgular; böylece GitHub'da tekrar arama yapmadan güncelleyebilirsiniz. - **Her zaman güncel** Kurulumlar varsayılan olarak en son yayımlanan sürümü kullanır; sürüm seçici aracılığıyla herhangi bir önceki sürümü de göz atıp kurma seçeneği mevcuttur. - **Açık kaynak ve genişletilebilir** Ağ, alan mantığı ve kullanıcı arayüzü arasında net bir ayrımla KMP'de yazılmıştır — fork'lamak, genişletmek veya uyarlamak kolaydır. --- ## 🔐 GitHub Store APK İmzalama Sertifikası Tüm resmi GitHub Store sürümleri şu sertifika parmak iziyle imzalanmıştır: SHA-256: `B7:F2:8E:19:8E:48:C1:93:B0:38:C6:5D:92:DD:F7:BC:07:7B:0D:B5:9E:BC:9B:25:0A:6D:AC:48:C1:18:03:CA` --- ## 🔑 GitHub OAuth Yapılandırması **Kısaca** 1. Bir GitHub OAuth App oluşturun 2. **Client ID**'yi kopyalayın 3. `local.properties` dosyasına yapıştırın
Tam kurulum kılavuzunu göster
### 1 - GitHub OAuth App Oluşturma Şuraya gidin: **GitHub → Settings → Developer settings → OAuth Apps → New OAuth App** | Alan | Değer | | ------------------------------ | ----------------------------------------------- | | **Application name** | İstediğiniz bir şey (örn. *GitHub Store Dev*) | | **Homepage URL** | `https://github.com/username/repo_name` | | **Authorization callback URL** | `githubstore://callback` | Ardından **Create application** düğmesine tıklayın. ### 2 - Client ID'nizi Kopyalayın Uygulamayı oluşturduktan sonra GitHub şunları gösterecek: - **Client ID** ← ihtiyacınız olan bu - **Client Secret** ← ❗ Bu proje için GEREKLİ DEĞİL ### 3 - Projenize Ekleyin Projenizin `local.properties` dosyasını (projenin kök dizini) açın ve şunu ekleyin: ```properties GITHUB_CLIENT_ID=YOUR_CLIENT_ID_HERE ``` ### 4 - Senkronize Edin ve Çalıştırın Projeyi senkronize edin ve uygulamayı çalıştırın. Artık GitHub ile giriş yapabilmeniz gerekir. ### ❗ Önemli Notlar - `local.properties` **Git'e eklenmez**, dolayısıyla Client ID'niz yerel kalır. - Bu proje yalnızca **Client ID**'ye ihtiyaç duyar (Client Secret'a değil). - Her geliştirici, geliştirme için kendi OAuth App'ini oluşturmalıdır.
--- ## ☕ Projeyi Destekleyin GitHub Store, lise öğrencisi tarafından geliştirilmekte ve bakımı yapılmaktadır. Desteğiniz ona şunlarda yardımcı olur: ✅ **Uygulamayı hatasız tutmak** — sorunlara yanıt vermek ve düzeltmeleri hızlıca yayımlamak ✅ **Topluluk tarafından istenen özellikleri eklemek** — kullanıcıların gerçekten ihtiyaç duyduklarını uygulamak ### 💖 Destek Yolları Buy Me a Coffee GitHub Sponsors **Şu an sponsor olamazsanız?** Sorun değil! Yine de şu şekillerde yardımcı olabilirsiniz: - ⭐ **Bu depoya yıldız vermek** — başkalarının GitHub Store'u keşfetmesine yardımcı olur - 🐛 **Hata bildirmek** — uygulamayı herkes için daha iyi hale getirir - 📢 **Arkadaşlarınızla paylaşmak** — diğer geliştiricilere ve yakınlarınıza söyleyin! - 💬 **[Discord](https://discord.gg/x9Cvh2Z9qS)'umuza katılmak** — geri bildirimleriniz yol haritasını şekillendirir Her türlü destek — finansal ya da değil — çok büyük anlam taşır ve bu projeyi yaşatır. Teşekkürler! --- ## ⚠️ Sorumluluk Reddi GitHub Store, yalnızca üçüncü taraf geliştiriciler tarafından GitHub'da zaten yayımlanmış olan sürüm dosyalarını keşfetmenize ve indirmenize yardımcı olur. Bu indirmelerin içeriği, güvenliği ve davranışı, bu projenin değil, ilgili yazarların ve dağıtımcıların münhasır sorumluluğundadır. GitHub Store'u kullanarak, indirilen herhangi bir yazılımı kendi riskinizle kurduğunuzu ve çalıştırdığınızı anlıyor ve kabul ediyorsunuz. Bu proje, herhangi bir kurucunun güvenli, kötü amaçlı yazılımdan arındırılmış ya da belirli bir amaç için uygun olduğunu incelemez, doğrulamaz veya garanti etmez. --- ## Yıldız Geçmişi Star History Chart ![Alt](https://repobeats.axiom.co/api/embed/20367dca127572e9c47db33850979d78df2c6a8b.svg "Repobeats analytics image") ## 📄 Lisans GitHub Store **Apache Lisansı, Sürüm 2.0** kapsamında yayımlanacaktır. ``` Copyright 2025 rainxchzed Licensed under the Apache License, Version 2.0 (the "License"); you may not use this project 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: docs/README-ZH.md ================================================

# GitHub Store

API Kotlin Compose Multiplatform Material







OpenHub-Store%2FGitHub-Store | Trendshift Featured|HelloGitHub

# 🗺️ 项目概述 GitHub Store 是一款专为 GitHub Releases 设计的跨平台应用商店,旨在简化开源软件的发现与安装过程。它能自动检测可安装的二进制文件(APK、EXE、DMG、AppImage、DEB、RPM),提供一键安装、更新追踪,并以整洁的应用商店风格界面展示仓库信息。 基于 Kotlin Multiplatform 和 Compose Multiplatform 开发,支持 Android 和桌面平台。
> [!CAUTION] > 自由开源的 Android 正面临威胁。Google 将把 Android 变成一个封闭平台,限制你自由安装所选应用的基本权利。让你的声音被听到 – [keepandroidopen.org](https://keepandroidopen.org/).

# 📔 Wiki 与资源 请查阅 GitHub Store [Wiki](https://github.com/OpenHub-Store/GitHub-Store/wiki) 获取常见问题解答和实用信息 🌐 **官方网站:** [github-store.org](https://github-store.org) 💬 **Discord:** [加入社区](https://discord.gg/x9Cvh2Z9qS) 📜 **隐私政策:** [github-store.org/privacy-policy](https://github-store.org/privacy-policy/)
---
### 📋 法律声明 GitHub Store 是一个独立的开源项目,与 GitHub, Inc. 无关。 该名称用于描述应用的功能(发现 GitHub Releases),不代表对商标的所有权主张。 GitHub® 是 GitHub, Inc. 的注册商标。
---

# 🔃 下载

Get it on Obtainium Get it on GitHub Store

> [!IMPORTANT] > **macOS 用户:** 你可能会看到 Apple 无法验证 GitHub Store 的警告。这是因为该应用在 App Store 之外分发,尚未经过公证。请通过「系统设置 → 隐私与安全性 → 仍然打开」来允许运行。 ---

# 🏆 媒体报道

Featured by HowToMen
HowToMen: 2026 年最佳 Android 应用 TOP 20 | 比 Google Play 商店更好的 12 个应用商店
HelloGitHub: 精选项目

--- ## 🚀 功能特性 - **智能发现** - 首页分为「Trending(趋势)」「Hot Release(热门发布)」「Most Popular(最受欢迎)」三大版块,支持按时间筛选。 - 仅显示拥有有效可安装文件的仓库。 - 平台感知话题评分,让 Android/桌面用户优先看到相关应用。 - 全面升级的搜索功能,相关性排名和性能均有显著提升。 - **Release 浏览器与安装** - Release 选择器,可浏览并安装任意版本,而非仅限最新版。 - 获取每个仓库的全部 Release 记录。 - 一键「安装最新版」操作,以及所有可用 Release 及其安装包的展开列表。 - 手动安装选项,并附带自动兼容性检测。 - **丰富的详情页面** - 应用名称、版本号及分享功能。 - Star 数、Fork 数、未关闭的 Issue 数。 - 渲染后的 README 内容(「关于此应用」)。 - 所选 Release 的 Markdown 格式发行说明。 - 带平台标签和文件大小的安装包列表。 - 深度链接支持 — 通过 URL 直接打开仓库详情。 - 开发者主页,可浏览开发者的仓库和动态。 - **应用管理** - 直接在 GitHub Store 中打开、卸载及降级已安装的应用。 - Android:APK 架构匹配(armv7/armv8)、软件包监控及更新追踪。 - 桌面端(Windows/macOS/Linux):将安装包下载到用户的「下载」文件夹,并以默认程序打开。 - **收藏的仓库** - 在应用内保存并浏览你在 GitHub 上收藏的仓库。 - **网络与性能** - 动态代理支持,可配置网络路由。 - 增强的缓存系统,加快加载速度,减少 API 用量。 --- ## 🔍 我的应用如何出现在 GitHub Store 中? GitHub Store 不使用任何私有索引或手动策划规则。 只要满足以下条件,你的项目便可自动显示: 1. **GitHub 上的公开仓库** - 可见性必须设置为 `public`。 2. **最新 Release 中包含可安装文件** - 最新 Release 中至少包含一个受支持扩展名的文件: - Android:`.apk` - Windows:`.exe`、`.msi` - macOS:`.dmg`、`.pkg` - Linux:`.deb`、`.rpm`、`.AppImage` - GitHub Store 会忽略 GitHub 自动生成的源码压缩包(`Source code (zip)` / `Source code (tar.gz)`)。 3. **可通过搜索 / 话题被发现** - 仓库通过 GitHub 公开搜索 API 获取。 - 话题、编程语言和描述影响排名: - Android 应用:`android`、`mobile`、`apk` 等话题。 - 桌面应用:`desktop`、`windows`、`linux`、`macos`、`compose-desktop`、`electron` 等话题。 - 拥有至少几个 Star 可以提高出现在 Trending/Hot Release/Most Popular 版块的概率。 只要你的仓库满足这些条件,GitHub Store 便可通过搜索自动找到并展示它 — 无需手动提交。 --- ## ✅ 优势 / 为什么使用 GitHub Store? - **无需再手动翻找 GitHub Releases** 只看那些真正为你的平台提供二进制文件的仓库。 - **了解你安装了什么** 追踪通过 GitHub Store 安装的应用(Android),并在有新版本时高亮提示,让你无需再回 GitHub 搜索即可完成更新。 - **始终保持最新** 安装默认使用最新发布的 Release,也可通过 Release 选择器浏览并安装任意历史版本。 - **开源且可扩展** 使用 KMP 编写,网络层、业务逻辑层与 UI 层清晰分离 — 易于 Fork、扩展或定制。 --- ## 🔐 GitHub Store APK 签名证书 所有官方 GitHub Store Release 均使用以下证书指纹签名: SHA-256: `B7:F2:8E:19:8E:48:C1:93:B0:38:C6:5D:92:DD:F7:BC:07:7B:0D:B5:9E:BC:9B:25:0A:6D:AC:48:C1:18:03:CA` --- ## 🔑 GitHub OAuth 配置 **简要步骤** 1. 创建一个 GitHub OAuth App 2. 复制 **Client ID** 3. 填入 `local.properties`
查看完整配置指南
### 1 - 创建 GitHub OAuth App 前往: **GitHub → Settings → Developer settings → OAuth Apps → New OAuth App** | 字段 | 值 | | ------------------------------ | ----------------------------------------------- | | **Application name** | 任意名称(例如 *GitHub Store Dev*) | | **Homepage URL** | `https://github.com/username/repo_name` | | **Authorization callback URL** | `githubstore://callback` | 然后点击 **Create application**。 ### 2 - 复制 Client ID 创建完成后,GitHub 将显示: - **Client ID** ← 这是你需要的 - **Client Secret** ← ❗ 本项目不需要 ### 3 - 添加到项目 打开项目根目录下的 `local.properties` 文件,添加: ```properties GITHUB_CLIENT_ID=YOUR_CLIENT_ID_HERE ``` ### 4 - 同步并运行 同步项目并运行应用。现在你应该可以使用 GitHub 账号登录了。 ### ❗ 重要说明 - `local.properties` **不会提交到 Git**,因此你的 Client ID 只保留在本地。 - 本项目只需要 **Client ID**(不需要 Client Secret)。 - 每位开发者应为自己的开发环境创建独立的 OAuth App。
--- ## ☕ 支持项目 GitHub Store 由一名高中生开发和维护。你的支持能帮助他: ✅ **保持应用无 Bug** — 响应 Issue 并快速发布修复 ✅ **添加社区请求的功能** — 实现用户真正需要的内容 ### 💖 支持方式 Buy Me a Coffee GitHub Sponsors **暂时无法赞助?** 没关系!你仍可以通过以下方式提供帮助: - ⭐ **给这个仓库点个 Star** — 帮助更多人发现 GitHub Store - 🐛 **反馈 Bug** — 让应用对所有人都更好用 - 📢 **分享给朋友** — 向其他开发者和朋友安利! - 💬 **加入我们的 [Discord](https://discord.gg/x9Cvh2Z9qS)** — 你的反馈将影响开发路线图 无论是金钱还是其他形式的支持,都意义重大,让这个项目得以延续。谢谢! --- ## ⚠️ 免责声明 GitHub Store 仅帮助你发现和下载由第三方开发者已在 GitHub 上发布的 Release 文件。 这些下载内容的安全性、行为及合规性由其各自的作者和分发者负责,与本项目无关。 使用 GitHub Store 即表示你理解并同意:安装和运行任何已下载的软件均需自行承担风险。 本项目不对任何安装包的安全性、是否包含恶意软件或是否适用于特定用途作出审查、验证或保证。 --- ## Star 历史 Star History Chart ![Alt](https://repobeats.axiom.co/api/embed/20367dca127572e9c47db33850979d78df2c6a8b.svg "Repobeats analytics image") ## 📄 许可证 GitHub Store 将在 **Apache License, Version 2.0** 下发布。 ``` Copyright 2025 rainxchzed Licensed under the Apache License, Version 2.0 (the "License"); you may not use this project 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: fastlane/metadata/android/en-US/full_description.txt ================================================ Github Store is a "play store" for GitHub releases that helps you discover and install apps directly from GitHub repositories. The app automatically discovers repositories that ship installable binaries (APK, EXE, DMG, etc.) and lets you install the latest release with one click. FEATURES - Smart discovery with Popular, Recently Updated, and New sections - Platform-aware filtering - only shows apps compatible with your device - Always installs from the latest published release - Rich app details with README, changelog, and statistics - GitHub login support for higher API rate limits - Material 3 design with dark mode support Built with Kotlin Multiplatform and Compose Multiplatform. ================================================ FILE: fastlane/metadata/android/en-US/short_description.txt ================================================ App store for GitHub releases - discover and install apps with one click ================================================ FILE: fastlane/metadata/android/en-US/title.txt ================================================ Github Store ================================================ FILE: feature/apps/CLAUDE.md ================================================ # CLAUDE.md - Apps Feature ## Purpose Manages installed applications. Lists all apps installed through GitHub Store, allows launching them, and checks for available updates. Primarily relevant on **Android** (apps section is hidden on Desktop). ## Module Structure ``` feature/apps/ ├── domain/ │ └── repository/AppsRepository.kt # Installed apps, launch, update check ├── data/ │ ├── di/SharedModule.kt # Koin: appsModule │ └── repository/AppsRepositoryImpl.kt # Implementation using core InstalledAppsRepository └── presentation/ ├── AppsViewModel.kt # State management for installed apps list ├── AppsState.kt # apps list, loading, error ├── AppsAction.kt # Refresh, OpenApp, CheckUpdates, clicks ├── AppsEvent.kt # One-off events ├── AppsRoot.kt # Main composable (apps list) └── components/ # App item cards, update badges ``` ## Key Interfaces ```kotlin interface AppsRepository { suspend fun getApps(): Flow> suspend fun openApp(installedApp: InstalledApp, onCantLaunchApp: () -> Unit = {}) suspend fun getLatestRelease(owner: String, repo: String): GithubRelease? } ``` ## Navigation Route: `GithubStoreGraph.AppsScreen` (data object, no params) ## Implementation Notes - Uses `InstalledAppsRepository` and `SyncInstalledAppsUseCase` from core/domain - `openApp()` uses `AppLauncher` from core/domain to launch the installed app - `getLatestRelease()` checks if a newer version is available - Platform-specific: `PackageMonitor` and `Installer` handle Android package management - The apps section in the home screen bottom nav is only visible on `Platform.ANDROID` ================================================ FILE: feature/apps/data/.gitignore ================================================ /build ================================================ FILE: feature/apps/data/build.gradle.kts ================================================ plugins { alias(libs.plugins.convention.kmp.library) alias(libs.plugins.convention.buildkonfig) } kotlin { sourceSets { commonMain { dependencies { implementation(libs.kotlin.stdlib) implementation(projects.core.domain) implementation(projects.core.data) implementation(projects.feature.apps.domain) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.datetime) implementation(libs.bundles.ktor.common) implementation(libs.bundles.koin.common) } } androidMain { dependencies { } } jvmMain { dependencies { } } } } ================================================ FILE: feature/apps/data/src/androidMain/AndroidManifest.xml ================================================ ================================================ FILE: feature/apps/data/src/commonMain/kotlin/zed/rainxch/apps/data/di/SharedModule.kt ================================================ package zed.rainxch.apps.data.di import org.koin.dsl.module import zed.rainxch.apps.data.repository.AppsRepositoryImpl import zed.rainxch.apps.domain.repository.AppsRepository val appsModule = module { single { AppsRepositoryImpl( appLauncher = get(), appsRepository = get(), logger = get(), httpClient = get(), packageMonitor = get(), tweaksRepository = get(), ) } } ================================================ FILE: feature/apps/data/src/commonMain/kotlin/zed/rainxch/apps/data/repository/AppsRepositoryImpl.kt ================================================ package zed.rainxch.apps.data.repository import io.ktor.client.HttpClient import io.ktor.client.request.get import io.ktor.client.request.header import io.ktor.client.request.parameter import io.ktor.http.HttpHeaders import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.serialization.json.Json import zed.rainxch.apps.domain.model.GithubRepoInfo import zed.rainxch.apps.domain.model.ImportResult import zed.rainxch.apps.domain.repository.AppsRepository import zed.rainxch.core.data.dto.GithubRepoNetworkModel import zed.rainxch.core.data.dto.ReleaseNetwork import zed.rainxch.core.data.mappers.toDomain import zed.rainxch.core.data.network.executeRequest import zed.rainxch.core.domain.logging.GitHubStoreLogger import zed.rainxch.core.domain.model.DeviceApp import zed.rainxch.core.domain.model.ExportedApp import zed.rainxch.core.domain.model.ExportedAppList import zed.rainxch.core.domain.model.GithubRelease import zed.rainxch.core.domain.model.InstallSource import zed.rainxch.core.domain.model.InstalledApp import zed.rainxch.core.domain.model.RateLimitException import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.repository.TweaksRepository import zed.rainxch.core.domain.system.PackageMonitor import zed.rainxch.core.domain.utils.AppLauncher import kotlin.time.Clock class AppsRepositoryImpl( private val appLauncher: AppLauncher, private val appsRepository: InstalledAppsRepository, private val logger: GitHubStoreLogger, private val httpClient: HttpClient, private val packageMonitor: PackageMonitor, private val tweaksRepository: TweaksRepository, ) : AppsRepository { private val json = Json { ignoreUnknownKeys = true } override suspend fun getApps(): Flow> = appsRepository.getAllInstalledApps() override suspend fun openApp( installedApp: InstalledApp, onCantLaunchApp: () -> Unit, ) { val canLaunch = appLauncher.canLaunchApp(installedApp) if (canLaunch) { appLauncher .launchApp(installedApp) .onFailure { error -> logger.error("Failed to launch app: ${error.message}") onCantLaunchApp() } } else { onCantLaunchApp() } } override suspend fun getLatestRelease( owner: String, repo: String, ): GithubRelease? = try { val includePreReleases = tweaksRepository.getIncludePreReleases().first() val releases = httpClient .executeRequest> { get("/repos/$owner/$repo/releases") { header(HttpHeaders.Accept, "application/vnd.github+json") parameter("per_page", 10) } }.getOrThrow() releases .asSequence() .filter { it.draft != true } .filter { includePreReleases || it.prerelease != true } .maxByOrNull { it.publishedAt ?: it.createdAt ?: "" } ?.toDomain() } catch (e: RateLimitException) { throw e } catch (e: Exception) { logger.error("Failed to fetch latest release for $owner/$repo: ${e.message}") null } override suspend fun getDeviceApps(): List = packageMonitor.getAllInstalledApps() override suspend fun getTrackedPackageNames(): Set = appsRepository .getAllInstalledApps() .first() .map { it.packageName } .toSet() override suspend fun fetchRepoInfo( owner: String, repo: String, ): GithubRepoInfo? = try { val repoModel = httpClient .executeRequest { get("/repos/$owner/$repo") { header(HttpHeaders.Accept, "application/vnd.github+json") } }.getOrThrow() val includePreReleases = tweaksRepository.getIncludePreReleases().first() val latestTag = try { val releases = httpClient .executeRequest> { get("/repos/$owner/$repo/releases") { header(HttpHeaders.Accept, "application/vnd.github+json") parameter("per_page", 5) } }.getOrThrow() releases .asSequence() .filter { it.draft != true } .filter { includePreReleases || it.prerelease != true } .maxByOrNull { it.publishedAt ?: it.createdAt ?: "" } ?.tagName } catch (_: Exception) { null } GithubRepoInfo( id = repoModel.id, name = repoModel.name, owner = repoModel.owner.login, ownerAvatarUrl = repoModel.owner.avatarUrl, description = repoModel.description, language = repoModel.language, htmlUrl = repoModel.htmlUrl, latestReleaseTag = latestTag, ) } catch (e: RateLimitException) { throw e } catch (e: Exception) { logger.error("Failed to fetch repo info for $owner/$repo: ${e.message}") null } override suspend fun linkAppToRepo( deviceApp: DeviceApp, repoInfo: GithubRepoInfo, ) { val now = Clock.System.now().toEpochMilliseconds() val installedApp = InstalledApp( packageName = deviceApp.packageName, repoId = repoInfo.id, repoName = repoInfo.name, repoOwner = repoInfo.owner, repoOwnerAvatarUrl = repoInfo.ownerAvatarUrl, repoDescription = repoInfo.description, primaryLanguage = repoInfo.language, repoUrl = repoInfo.htmlUrl, installedVersion = deviceApp.versionName ?: "unknown", installedAssetName = null, installedAssetUrl = null, latestVersion = repoInfo.latestReleaseTag, latestAssetName = null, latestAssetUrl = null, latestAssetSize = null, appName = deviceApp.appName, installSource = InstallSource.MANUAL, installedAt = now, lastCheckedAt = 0L, lastUpdatedAt = now, isUpdateAvailable = false, updateCheckEnabled = true, releaseNotes = null, systemArchitecture = "", fileExtension = "apk", isPendingInstall = false, installedVersionName = deviceApp.versionName, installedVersionCode = deviceApp.versionCode, signingFingerprint = deviceApp.signingFingerprint, ) appsRepository.saveInstalledApp(installedApp) } override suspend fun exportApps(): String { val apps = appsRepository.getAllInstalledApps().first() val exported = ExportedAppList( version = 1, exportedAt = Clock.System.now().toEpochMilliseconds(), apps = apps.map { app -> ExportedApp( packageName = app.packageName, repoOwner = app.repoOwner, repoName = app.repoName, repoUrl = app.repoUrl, ) }, ) return json.encodeToString(ExportedAppList.serializer(), exported) } override suspend fun importApps(json: String): ImportResult { val exportedList = try { this@AppsRepositoryImpl.json.decodeFromString(ExportedAppList.serializer(), json) } catch (e: Exception) { logger.error("Failed to parse import JSON: ${e.message}") return ImportResult(imported = 0, skipped = 0, failed = 1) } val trackedPackages = getTrackedPackageNames() var imported = 0 var skipped = 0 var failed = 0 for (exportedApp in exportedList.apps) { if (exportedApp.packageName in trackedPackages) { skipped++ continue } try { val repoInfo = fetchRepoInfo(exportedApp.repoOwner, exportedApp.repoName) if (repoInfo == null) { failed++ continue } val systemInfo = packageMonitor.getInstalledPackageInfo(exportedApp.packageName) val deviceApp = DeviceApp( packageName = exportedApp.packageName, appName = exportedApp.repoName, versionName = systemInfo?.versionName, versionCode = systemInfo?.versionCode ?: 0L, signingFingerprint = systemInfo?.signingFingerprint, ) linkAppToRepo(deviceApp, repoInfo) imported++ } catch (e: Exception) { logger.error("Failed to import ${exportedApp.repoOwner}/${exportedApp.repoName}: ${e.message}") failed++ } } return ImportResult(imported = imported, skipped = skipped, failed = failed) } } ================================================ FILE: feature/apps/domain/.gitignore ================================================ /build ================================================ FILE: feature/apps/domain/build.gradle.kts ================================================ plugins { alias(libs.plugins.convention.kmp.library) } kotlin { sourceSets { commonMain { dependencies { implementation(libs.kotlin.stdlib) implementation(projects.core.domain) implementation(libs.kotlinx.coroutines.core) } } androidMain { dependencies { } } jvmMain { dependencies { } } } } ================================================ FILE: feature/apps/domain/src/androidMain/AndroidManifest.xml ================================================ ================================================ FILE: feature/apps/domain/src/commonMain/kotlin/zed/rainxch/apps/domain/model/GithubRepoInfo.kt ================================================ package zed.rainxch.apps.domain.model data class GithubRepoInfo( val id: Long, val name: String, val owner: String, val ownerAvatarUrl: String, val description: String?, val language: String?, val htmlUrl: String, val latestReleaseTag: String?, ) ================================================ FILE: feature/apps/domain/src/commonMain/kotlin/zed/rainxch/apps/domain/model/ImportResult.kt ================================================ package zed.rainxch.apps.domain.model data class ImportResult( val imported: Int, val skipped: Int, val failed: Int, ) ================================================ FILE: feature/apps/domain/src/commonMain/kotlin/zed/rainxch/apps/domain/repository/AppsRepository.kt ================================================ package zed.rainxch.apps.domain.repository import kotlinx.coroutines.flow.Flow import zed.rainxch.apps.domain.model.GithubRepoInfo import zed.rainxch.apps.domain.model.ImportResult import zed.rainxch.core.domain.model.DeviceApp import zed.rainxch.core.domain.model.GithubRelease import zed.rainxch.core.domain.model.InstalledApp interface AppsRepository { suspend fun getApps(): Flow> suspend fun openApp( installedApp: InstalledApp, onCantLaunchApp: () -> Unit = { }, ) suspend fun getLatestRelease( owner: String, repo: String, ): GithubRelease? suspend fun getDeviceApps(): List suspend fun getTrackedPackageNames(): Set suspend fun fetchRepoInfo(owner: String, repo: String): GithubRepoInfo? suspend fun linkAppToRepo(deviceApp: DeviceApp, repoInfo: GithubRepoInfo) suspend fun exportApps(): String suspend fun importApps(json: String): ImportResult } ================================================ FILE: feature/apps/presentation/.gitignore ================================================ /build ================================================ FILE: feature/apps/presentation/build.gradle.kts ================================================ plugins { alias(libs.plugins.convention.cmp.feature) } kotlin { sourceSets { commonMain { dependencies { implementation(libs.kotlin.stdlib) implementation(projects.core.domain) implementation(projects.core.presentation) implementation(projects.feature.apps.domain) implementation(libs.androidx.compose.ui.tooling.preview) implementation(compose.components.resources) implementation(libs.bundles.landscapist) implementation(libs.liquid) implementation(libs.kotlinx.collections.immutable) } } androidMain { dependencies { } } jvmMain { dependencies { } } } } ================================================ FILE: feature/apps/presentation/src/androidMain/AndroidManifest.xml ================================================ ================================================ FILE: feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsAction.kt ================================================ package zed.rainxch.apps.presentation import zed.rainxch.apps.presentation.model.InstalledAppUi import zed.rainxch.apps.presentation.model.DeviceAppUi import zed.rainxch.apps.presentation.model.GithubAssetUi sealed interface AppsAction { data object OnNavigateBackClick : AppsAction data class OnSearchChange( val query: String, ) : AppsAction data class OnOpenApp( val app: InstalledAppUi, ) : AppsAction data class OnUpdateApp( val app: InstalledAppUi, ) : AppsAction data class OnCancelUpdate( val packageName: String, ) : AppsAction data object OnUpdateAll : AppsAction data object OnCancelUpdateAll : AppsAction data object OnCheckAllForUpdates : AppsAction data object OnRefresh : AppsAction data class OnNavigateToRepo( val repoId: Long, ) : AppsAction data class OnUninstallApp( val app: InstalledAppUi, ) : AppsAction // Uninstall confirmation data class OnUninstallConfirmed(val app: InstalledAppUi) : AppsAction data object OnDismissUninstallDialog : AppsAction // Link app to repo data object OnAddByLinkClick : AppsAction data object OnDismissLinkSheet : AppsAction data class OnDeviceAppSearchChange(val query: String) : AppsAction data class OnDeviceAppSelected(val app: DeviceAppUi) : AppsAction data class OnRepoUrlChanged(val url: String) : AppsAction data object OnValidateAndLinkRepo : AppsAction data object OnBackToAppPicker : AppsAction data class OnLinkAssetSelected(val asset: GithubAssetUi) : AppsAction data object OnBackToEnterUrl : AppsAction // Export/Import data object OnExportApps : AppsAction data object OnImportApps : AppsAction } ================================================ FILE: feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsEvent.kt ================================================ package zed.rainxch.apps.presentation import zed.rainxch.apps.domain.model.ImportResult sealed interface AppsEvent { data class ShowError( val message: String, ) : AppsEvent data class ShowSuccess( val message: String, ) : AppsEvent data class NavigateToRepo( val repoId: Long, ) : AppsEvent data class AppLinkedSuccessfully( val appName: String, ) : AppsEvent data class ImportComplete( val result: ImportResult, ) : AppsEvent } ================================================ FILE: feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsRoot.kt ================================================ @file:OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) package zed.rainxch.apps.presentation 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.Row 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.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.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Cancel import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Update import androidx.compose.material.icons.outlined.DeleteOutline import androidx.compose.material.icons.outlined.FileDownload import androidx.compose.material.icons.outlined.FileUpload import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearWavyProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBar import androidx.compose.material3.pulltorefresh.PullToRefreshBox 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 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.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.skydoves.landscapist.coil3.CoilImage import io.github.fletchmckee.liquid.liquefiable import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import zed.rainxch.apps.presentation.components.LinkAppBottomSheet import zed.rainxch.apps.presentation.model.AppItem import zed.rainxch.apps.presentation.model.UpdateAllProgress import zed.rainxch.apps.presentation.model.UpdateState import zed.rainxch.core.presentation.components.ExpressiveCard import zed.rainxch.core.presentation.locals.LocalBottomNavigationHeight import zed.rainxch.core.presentation.locals.LocalBottomNavigationLiquid import zed.rainxch.core.presentation.theme.GithubStoreTheme import zed.rainxch.core.presentation.utils.ObserveAsEvents import zed.rainxch.githubstore.core.presentation.res.Res import zed.rainxch.githubstore.core.presentation.res.add_by_link import zed.rainxch.githubstore.core.presentation.res.cancel import zed.rainxch.githubstore.core.presentation.res.check_for_updates import zed.rainxch.githubstore.core.presentation.res.checking import zed.rainxch.githubstore.core.presentation.res.checking_for_updates import zed.rainxch.githubstore.core.presentation.res.confirm_uninstall_message import zed.rainxch.githubstore.core.presentation.res.confirm_uninstall_title import zed.rainxch.githubstore.core.presentation.res.currently_updating import zed.rainxch.githubstore.core.presentation.res.downloading import zed.rainxch.githubstore.core.presentation.res.error_with_message import zed.rainxch.githubstore.core.presentation.res.export_apps import zed.rainxch.githubstore.core.presentation.res.import_apps import zed.rainxch.githubstore.core.presentation.res.installed_apps import zed.rainxch.githubstore.core.presentation.res.installing import zed.rainxch.githubstore.core.presentation.res.last_checked import zed.rainxch.githubstore.core.presentation.res.last_checked_hours_ago import zed.rainxch.githubstore.core.presentation.res.last_checked_just_now import zed.rainxch.githubstore.core.presentation.res.last_checked_minutes_ago import zed.rainxch.githubstore.core.presentation.res.no_apps_found import zed.rainxch.githubstore.core.presentation.res.open import zed.rainxch.githubstore.core.presentation.res.pending_install import zed.rainxch.githubstore.core.presentation.res.search_your_apps import zed.rainxch.githubstore.core.presentation.res.uninstall import zed.rainxch.githubstore.core.presentation.res.update import zed.rainxch.githubstore.core.presentation.res.update_all import zed.rainxch.githubstore.core.presentation.res.updated_successfully import zed.rainxch.githubstore.core.presentation.res.updating_x_of_y @Composable fun AppsRoot( onNavigateBack: () -> Unit, onNavigateToRepo: (repoId: Long) -> Unit, viewModel: AppsViewModel = koinViewModel(), state: AppsState, ) { val snackbarHostState = remember { SnackbarHostState() } val coroutineScope = rememberCoroutineScope() ObserveAsEvents(viewModel.events) { event -> when (event) { is AppsEvent.NavigateToRepo -> { onNavigateToRepo(event.repoId) } is AppsEvent.ShowError -> { coroutineScope.launch { snackbarHostState.showSnackbar(event.message) } } is AppsEvent.ShowSuccess -> { coroutineScope.launch { snackbarHostState.showSnackbar(event.message) } } is AppsEvent.AppLinkedSuccessfully -> { // handled by ShowSuccess } is AppsEvent.ImportComplete -> { // handled by ShowSuccess } } } AppsScreen( state = state, onAction = { action -> when (action) { AppsAction.OnNavigateBackClick -> { onNavigateBack() } else -> { viewModel.onAction(action) } } }, snackbarHostState = snackbarHostState, ) } @Composable fun AppsScreen( state: AppsState, onAction: (AppsAction) -> Unit, snackbarHostState: SnackbarHostState, ) { val liquidState = LocalBottomNavigationLiquid.current val bottomNavHeight = LocalBottomNavigationHeight.current var showOverflowMenu by remember { mutableStateOf(false) } Scaffold( topBar = { TopAppBar( title = { Text( text = stringResource(Res.string.installed_apps), style = MaterialTheme.typography.titleMediumEmphasized, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface, ) }, actions = { IconButton( onClick = { onAction(AppsAction.OnCheckAllForUpdates) }, ) { Icon( imageVector = Icons.Default.Refresh, contentDescription = stringResource(Res.string.check_for_updates), ) } Box { IconButton(onClick = { showOverflowMenu = true }) { Icon( imageVector = Icons.Outlined.MoreVert, contentDescription = null, ) } DropdownMenu( expanded = showOverflowMenu, onDismissRequest = { showOverflowMenu = false }, ) { DropdownMenuItem( text = { Text(stringResource(Res.string.export_apps)) }, onClick = { showOverflowMenu = false onAction(AppsAction.OnExportApps) }, leadingIcon = { Icon(Icons.Outlined.FileUpload, contentDescription = null) }, ) DropdownMenuItem( text = { Text(stringResource(Res.string.import_apps)) }, onClick = { showOverflowMenu = false onAction(AppsAction.OnImportApps) }, leadingIcon = { Icon(Icons.Outlined.FileDownload, contentDescription = null) }, ) } } }, ) }, floatingActionButton = { FloatingActionButton( onClick = { onAction(AppsAction.OnAddByLinkClick) }, modifier = Modifier.padding(bottom = bottomNavHeight), ) { Icon( imageVector = Icons.Default.Add, contentDescription = stringResource(Res.string.add_by_link), ) } }, snackbarHost = { SnackbarHost( hostState = snackbarHostState, modifier = Modifier.padding(bottomNavHeight + 16.dp), ) }, modifier = Modifier .then( if (state.isLiquidGlassEnabled) { Modifier.liquefiable(liquidState) } else { Modifier }, ), ) { innerPadding -> // Link app bottom sheet if (state.showLinkSheet) { LinkAppBottomSheet( state = state, onAction = onAction, ) } // Uninstall confirmation dialog state.appPendingUninstall?.let { app -> AlertDialog( onDismissRequest = { onAction(AppsAction.OnDismissUninstallDialog) }, title = { Text( text = stringResource(Res.string.confirm_uninstall_title), fontWeight = FontWeight.Bold, ) }, text = { Text( text = stringResource(Res.string.confirm_uninstall_message, app.appName), ) }, confirmButton = { TextButton( onClick = { onAction(AppsAction.OnUninstallConfirmed(app)) }, ) { Text( text = stringResource(Res.string.uninstall), color = MaterialTheme.colorScheme.error, ) } }, dismissButton = { TextButton( onClick = { onAction(AppsAction.OnDismissUninstallDialog) }, ) { Text(text = stringResource(Res.string.cancel)) } }, ) } PullToRefreshBox( isRefreshing = state.isRefreshing, onRefresh = { onAction(AppsAction.OnRefresh) }, modifier = Modifier .fillMaxSize() .padding(innerPadding), ) { Column( modifier = Modifier.fillMaxSize(), ) { TextField( value = state.searchQuery, onValueChange = { onAction(AppsAction.OnSearchChange(it)) }, leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, placeholder = { Text(stringResource(Res.string.search_your_apps)) }, modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp), shape = CircleShape, colors = TextFieldDefaults.colors( focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent, ), ) if (state.isCheckingForUpdates) { Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { CircularProgressIndicator( modifier = Modifier.size(14.dp), strokeWidth = 2.dp, ) Text( text = stringResource(Res.string.checking_for_updates), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } else if (state.lastCheckedTimestamp != null) { Text( text = stringResource( Res.string.last_checked, formatLastChecked(state.lastCheckedTimestamp), ), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), ) } val hasUpdates = state.apps.any { it.installedApp.isUpdateAvailable } if (hasUpdates && !state.isUpdatingAll) { Button( onClick = { onAction(AppsAction.OnUpdateAll) }, modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp), enabled = state.updateAllButtonEnabled, ) { Icon( imageVector = Icons.Default.Update, contentDescription = null, ) Spacer(Modifier.width(8.dp)) Text( text = stringResource(Res.string.update_all), ) } } if (state.isUpdatingAll && state.updateAllProgress != null) { UpdateAllProgressCard( progress = state.updateAllProgress, onCancel = { onAction(AppsAction.OnCancelUpdateAll) }, ) } when { state.isLoading -> { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { CircularProgressIndicator() } } state.filteredApps.isEmpty() -> { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { Text( text = stringResource(Res.string.no_apps_found), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onBackground, ) } } else -> { LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { items( items = state.filteredApps, key = { it.installedApp.packageName }, ) { appItem -> AppItemCard( appItem = appItem, onOpenClick = { onAction(AppsAction.OnOpenApp(appItem.installedApp)) }, onUpdateClick = { onAction(AppsAction.OnUpdateApp(appItem.installedApp)) }, onCancelClick = { onAction(AppsAction.OnCancelUpdate(appItem.installedApp.packageName)) }, onUninstallClick = { onAction(AppsAction.OnUninstallApp(appItem.installedApp)) }, onRepoClick = { onAction(AppsAction.OnNavigateToRepo(appItem.installedApp.repoId)) }, modifier = Modifier .then( if (state.isLiquidGlassEnabled) { Modifier.liquefiable(liquidState) } else { Modifier }, ), ) } item { Spacer(Modifier.height(bottomNavHeight + 32.dp)) } } } } } } } } @Composable fun UpdateAllProgressCard( progress: UpdateAllProgress, onCancel: () -> Unit, ) { Card( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp), ) { Column( modifier = Modifier.padding(16.dp), ) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { Text( text = stringResource( Res.string.updating_x_of_y, progress.current, progress.total, ), style = MaterialTheme.typography.titleMedium, ) IconButton(onClick = onCancel) { Icon( Icons.Default.Close, contentDescription = stringResource(Res.string.cancel), ) } } Spacer(Modifier.height(8.dp)) Text( text = stringResource( Res.string.currently_updating, progress.currentAppName, ), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(Modifier.height(12.dp)) LinearWavyProgressIndicator( progress = { progress.current.toFloat() / progress.total }, modifier = Modifier.fillMaxWidth(), ) } } } @Composable fun AppItemCard( appItem: AppItem, onOpenClick: () -> Unit, onUpdateClick: () -> Unit, onCancelClick: () -> Unit, onUninstallClick: () -> Unit, onRepoClick: () -> Unit, modifier: Modifier = Modifier, ) { val app = appItem.installedApp ExpressiveCard( onClick = onRepoClick, modifier = modifier, ) { Column( modifier = Modifier .clip(RoundedCornerShape(32.dp)) .padding(16.dp), ) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), ) { CoilImage( imageModel = { app.repoOwnerAvatarUrl }, modifier = Modifier .size(64.dp) .clip(CircleShape), loading = { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { CircularWavyProgressIndicator() } }, ) Column(modifier = Modifier.weight(1f)) { Text( text = app.appName, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Bold, ) Text( text = app.repoOwner, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) when { app.isPendingInstall -> { Text( text = stringResource(Res.string.pending_install), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.tertiary, ) } app.isUpdateAvailable -> { Text( text = "${app.installedVersion} → ${app.latestVersion}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary, ) } else -> { Text( text = app.installedVersion, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } } if (app.repoDescription != null) { Spacer(Modifier.height(8.dp)) Text( text = app.repoDescription, style = MaterialTheme.typography.bodyMediumEmphasized, maxLines = 2, overflow = TextOverflow.Ellipsis, ) } Spacer(Modifier.height(12.dp)) when (val state = appItem.updateState) { is UpdateState.Downloading -> { Column { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, ) { Text( text = stringResource(Res.string.downloading), style = MaterialTheme.typography.bodySmall, ) if (appItem.downloadProgress != null) { Text( text = "${appItem.downloadProgress}%", style = MaterialTheme.typography.bodySmall, ) } } Spacer(Modifier.height(4.dp)) LinearWavyProgressIndicator( progress = { (appItem.downloadProgress ?: 0) / 100f }, modifier = Modifier.fillMaxWidth(), ) } } is UpdateState.Installing -> { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { CircularProgressIndicator( modifier = Modifier.size(16.dp), strokeWidth = 2.dp, ) Text( text = stringResource(Res.string.installing), style = MaterialTheme.typography.bodySmall, ) } } is UpdateState.CheckingUpdate -> { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { CircularProgressIndicator( modifier = Modifier.size(16.dp), strokeWidth = 2.dp, ) Text( text = stringResource(Res.string.checking), style = MaterialTheme.typography.bodySmall, ) } } is UpdateState.Success -> { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Icon( imageVector = Icons.Default.CheckCircle, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp), ) Text( text = stringResource(Res.string.updated_successfully), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary, ) } } is UpdateState.Error -> { Text( text = stringResource(Res.string.error_with_message, state.message), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, ) } UpdateState.Idle -> {} } Spacer(Modifier.height(12.dp)) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { if (!app.isPendingInstall && appItem.updateState !is UpdateState.Downloading && appItem.updateState !is UpdateState.Installing && appItem.updateState !is UpdateState.CheckingUpdate ) { IconButton( onClick = onUninstallClick, ) { Icon( imageVector = Icons.Outlined.DeleteOutline, contentDescription = stringResource(Res.string.uninstall), tint = MaterialTheme.colorScheme.error, ) } } Button( shapes = ButtonDefaults.shapes(), onClick = onOpenClick, modifier = Modifier.weight(1f), enabled = !app.isPendingInstall && appItem.updateState !is UpdateState.Downloading && appItem.updateState !is UpdateState.Installing, ) { Icon( imageVector = Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null, modifier = Modifier.size(18.dp), ) Spacer(Modifier.width(4.dp)) Text( text = stringResource(Res.string.open), ) } when (appItem.updateState) { is UpdateState.Downloading, is UpdateState.Installing, is UpdateState.CheckingUpdate -> { Button( onClick = onCancelClick, modifier = Modifier.weight(1f), colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.errorContainer, contentColor = MaterialTheme.colorScheme.onErrorContainer, ), ) { Icon( imageVector = Icons.Default.Cancel, contentDescription = stringResource(Res.string.cancel), modifier = Modifier.size(18.dp), ) Spacer(Modifier.width(4.dp)) Text( text = stringResource(Res.string.cancel), ) } } else -> { if (app.isUpdateAvailable && !app.isPendingInstall) { Button( onClick = onUpdateClick, modifier = Modifier.weight(1f), ) { Icon( imageVector = Icons.Default.Update, contentDescription = null, modifier = Modifier.size(18.dp), ) Spacer(Modifier.width(4.dp)) Text( text = stringResource(Res.string.update), ) } } } } } } } } @Composable private fun formatLastChecked(timestamp: Long): String { val now = System.currentTimeMillis() val diff = now - timestamp val minutes = diff / (60 * 1000) val hours = diff / (60 * 60 * 1000) return when { minutes < 1 -> stringResource(Res.string.last_checked_just_now) minutes < 60 -> stringResource(Res.string.last_checked_minutes_ago, minutes.toInt()) else -> stringResource(Res.string.last_checked_hours_ago, hours.toInt()) } } @Preview @Composable private fun Preview() { GithubStoreTheme { AppsScreen( state = AppsState(), onAction = {}, snackbarHostState = SnackbarHostState(), ) } } ================================================ FILE: feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsState.kt ================================================ package zed.rainxch.apps.presentation import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import zed.rainxch.apps.domain.model.GithubRepoInfo import zed.rainxch.apps.presentation.model.AppItem import zed.rainxch.apps.presentation.model.DeviceAppUi import zed.rainxch.apps.presentation.model.GithubAssetUi import zed.rainxch.apps.presentation.model.GithubRepoInfoUi import zed.rainxch.apps.presentation.model.InstalledAppUi import zed.rainxch.apps.presentation.model.UpdateAllProgress import zed.rainxch.core.domain.model.DeviceApp import zed.rainxch.core.domain.model.GithubAsset data class AppsState( val apps: ImmutableList = persistentListOf(), val filteredApps: ImmutableList = persistentListOf(), val searchQuery: String = "", val isLoading: Boolean = false, val isUpdatingAll: Boolean = false, val updateAllProgress: UpdateAllProgress? = null, val updateAllButtonEnabled: Boolean = true, val isCheckingForUpdates: Boolean = false, val lastCheckedTimestamp: Long? = null, val isRefreshing: Boolean = false, val isLiquidGlassEnabled: Boolean = true, // Link app to repo val showLinkSheet: Boolean = false, val linkStep: LinkStep = LinkStep.PickApp, val deviceApps: ImmutableList = persistentListOf(), val deviceAppSearchQuery: String = "", val selectedDeviceApp: DeviceAppUi? = null, val repoUrl: String = "", val isValidatingRepo: Boolean = false, val repoValidationError: String? = null, val linkValidationStatus: String? = null, val linkInstallableAssets: ImmutableList = persistentListOf(), val linkSelectedAsset: GithubAssetUi? = null, val linkDownloadProgress: Int? = null, val fetchedRepoInfo: GithubRepoInfoUi? = null, // Export/Import val isExporting: Boolean = false, val isImporting: Boolean = false, // Uninstall confirmation val appPendingUninstall: InstalledAppUi? = null, ) { val filteredDeviceApps: ImmutableList get() = if (deviceAppSearchQuery.isBlank()) { deviceApps.toImmutableList() } else { deviceApps .filter { it.appName.contains(deviceAppSearchQuery, ignoreCase = true) || it.packageName.contains(deviceAppSearchQuery, ignoreCase = true) }.toImmutableList() } } enum class LinkStep { PickApp, EnterUrl, PickAsset, } ================================================ FILE: feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt ================================================ package zed.rainxch.apps.presentation import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import org.jetbrains.compose.resources.getString import zed.rainxch.apps.domain.repository.AppsRepository import zed.rainxch.apps.presentation.mappers.toDomain import zed.rainxch.apps.presentation.mappers.toUi import zed.rainxch.apps.presentation.model.AppItem import zed.rainxch.apps.presentation.model.GithubAssetUi import zed.rainxch.apps.presentation.model.InstalledAppUi import zed.rainxch.apps.presentation.model.UpdateAllProgress import zed.rainxch.apps.presentation.model.UpdateState import zed.rainxch.core.domain.logging.GitHubStoreLogger import zed.rainxch.core.domain.model.InstalledApp import zed.rainxch.core.domain.model.RateLimitException import zed.rainxch.core.domain.network.Downloader import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.repository.TweaksRepository import zed.rainxch.core.domain.system.Installer import zed.rainxch.core.domain.use_cases.SyncInstalledAppsUseCase import zed.rainxch.core.domain.utils.ShareManager import zed.rainxch.githubstore.core.presentation.res.* import java.io.File class AppsViewModel( private val appsRepository: AppsRepository, private val installer: Installer, private val downloader: Downloader, private val installedAppsRepository: InstalledAppsRepository, private val syncInstalledAppsUseCase: SyncInstalledAppsUseCase, private val logger: GitHubStoreLogger, private val shareManager: ShareManager, private val tweaksRepository: TweaksRepository, ) : ViewModel() { companion object { private const val UPDATE_CHECK_COOLDOWN_MS = 30 * 60 * 1000L } private var hasLoadedInitialData = false private val activeUpdates = mutableMapOf() private var updateAllJob: Job? = null private var lastAutoCheckTimestamp: Long = 0L private val _state = MutableStateFlow(AppsState()) val state = _state .onStart { if (!hasLoadedInitialData) { loadApps() observeLiquidGlassEnabled() hasLoadedInitialData = true } }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000L), initialValue = AppsState(), ) private fun observeLiquidGlassEnabled() { viewModelScope.launch { tweaksRepository.getLiquidGlassEnabled().collect { enabled -> _state.update { it.copy( isLiquidGlassEnabled = enabled, ) } } } } private val _events = Channel() val events = _events.receiveAsFlow() private fun loadApps() { viewModelScope.launch { _state.update { it.copy(isLoading = true) } try { val syncResult = syncInstalledAppsUseCase() if (syncResult.isFailure) { logger.error("Sync had issues but continuing: ${syncResult.exceptionOrNull()?.message}") } appsRepository.getApps().collect { apps -> val appItems = apps .map { it.toUi() } .map { app -> val existing = _state.value.apps.find { it.installedApp.packageName == app.packageName } AppItem( installedApp = app, updateState = existing?.updateState ?: UpdateState.Idle, downloadProgress = existing?.downloadProgress, error = existing?.error, ) }.sortedByDescending { it.installedApp.isUpdateAvailable } .toImmutableList() _state.update { it.copy( apps = appItems, isLoading = false, updateAllButtonEnabled = appItems.any { item -> item.installedApp.isUpdateAvailable }, ) } filterApps() } } catch (e: Exception) { logger.error("Failed to load apps: ${e.message}") _state.update { it.copy(isLoading = false) } } autoCheckForUpdatesIfNeeded() } } private fun autoCheckForUpdatesIfNeeded() { val now = System.currentTimeMillis() if (now - lastAutoCheckTimestamp < UPDATE_CHECK_COOLDOWN_MS) { logger.debug("Skipping auto-check: last check was ${(now - lastAutoCheckTimestamp) / 1000}s ago") return } checkAllForUpdates() } private fun checkAllForUpdates() { viewModelScope.launch { _state.update { it.copy(isCheckingForUpdates = true) } try { syncInstalledAppsUseCase() installedAppsRepository.checkAllForUpdates() val now = System.currentTimeMillis() lastAutoCheckTimestamp = now _state.update { it.copy(lastCheckedTimestamp = now) } } catch (e: Exception) { logger.error("Check all for updates failed: ${e.message}") } finally { _state.update { it.copy(isCheckingForUpdates = false) } } } } private fun refresh() { viewModelScope.launch { _state.update { it.copy(isRefreshing = true) } try { syncInstalledAppsUseCase() installedAppsRepository.checkAllForUpdates() val now = System.currentTimeMillis() lastAutoCheckTimestamp = now _state.update { it.copy(lastCheckedTimestamp = now) } } catch (e: Exception) { logger.error("Refresh failed: ${e.message}") } finally { _state.update { it.copy(isRefreshing = false) } } } } fun onAction(action: AppsAction) { when (action) { AppsAction.OnNavigateBackClick -> { } is AppsAction.OnSearchChange -> { _state.update { it.copy(searchQuery = action.query) } filterApps() } is AppsAction.OnOpenApp -> { openApp(action.app) } is AppsAction.OnUpdateApp -> { updateSingleApp(action.app) } is AppsAction.OnCancelUpdate -> { cancelUpdate(action.packageName) } AppsAction.OnUpdateAll -> { updateAllApps() } AppsAction.OnCancelUpdateAll -> { cancelAllUpdates() } AppsAction.OnCheckAllForUpdates -> { checkAllForUpdates() } AppsAction.OnRefresh -> { refresh() } is AppsAction.OnNavigateToRepo -> { viewModelScope.launch { _events.send(AppsEvent.NavigateToRepo(action.repoId)) } } is AppsAction.OnUninstallApp -> { _state.update { it.copy(appPendingUninstall = action.app) } } AppsAction.OnAddByLinkClick -> { openLinkSheet() } AppsAction.OnDismissLinkSheet -> { dismissLinkSheet() } is AppsAction.OnDeviceAppSearchChange -> { _state.update { it.copy(deviceAppSearchQuery = action.query) } } is AppsAction.OnDeviceAppSelected -> { _state.update { it.copy( selectedDeviceApp = action.app, linkStep = LinkStep.EnterUrl, repoUrl = "", repoValidationError = null, fetchedRepoInfo = null, ) } } is AppsAction.OnRepoUrlChanged -> { _state.update { it.copy( repoUrl = action.url, repoValidationError = null, ) } } AppsAction.OnValidateAndLinkRepo -> { validateAndLinkRepo() } AppsAction.OnBackToAppPicker -> { _state.update { it.copy( linkStep = LinkStep.PickApp, selectedDeviceApp = null, repoUrl = "", repoValidationError = null, fetchedRepoInfo = null, linkInstallableAssets = persistentListOf(), linkSelectedAsset = null, linkDownloadProgress = null, ) } } is AppsAction.OnLinkAssetSelected -> { validateWithAsset(action.asset) } AppsAction.OnBackToEnterUrl -> { _state.update { it.copy( linkStep = LinkStep.EnterUrl, linkInstallableAssets = persistentListOf(), linkSelectedAsset = null, linkDownloadProgress = null, linkValidationStatus = null, repoValidationError = null, ) } } AppsAction.OnExportApps -> { exportApps() } AppsAction.OnImportApps -> { importAppsFromFile() } is AppsAction.OnUninstallConfirmed -> { uninstallApp(action.app) _state.update { it.copy(appPendingUninstall = null) } } AppsAction.OnDismissUninstallDialog -> { _state.update { it.copy(appPendingUninstall = null) } } } } private fun filterApps() { _state.update { current -> current.copy( filteredApps = computeFilteredApps(current.apps, current.searchQuery), ) } } private fun computeFilteredApps( apps: ImmutableList, query: String, ): ImmutableList = if (query.isBlank()) { apps .sortedBy { it.installedApp.isUpdateAvailable } .toImmutableList() } else { apps .filter { appItem -> appItem.installedApp.appName.contains(query, ignoreCase = true) || appItem.installedApp.repoOwner.contains(query, ignoreCase = true) }.sortedBy { it.installedApp.isUpdateAvailable } .toImmutableList() } private fun uninstallApp(app: InstalledAppUi) { viewModelScope.launch { try { installer.uninstall(app.packageName) logger.debug("Requested uninstall for ${app.packageName}") } catch (e: Exception) { logger.error("Failed to request uninstall for ${app.packageName}: ${e.message}") _events.send( AppsEvent.ShowError( getString(Res.string.failed_to_uninstall, app.appName), ), ) } } } private fun openApp(app: InstalledAppUi) { viewModelScope.launch { try { appsRepository.openApp( installedApp = app.toDomain(), onCantLaunchApp = { viewModelScope.launch { _events.send( AppsEvent.ShowError( getString( Res.string.cannot_launch, app.appName, ), ), ) } }, ) } catch (e: Exception) { logger.error("Failed to open app: ${e.message}") _events.send( AppsEvent.ShowError( getString( Res.string.failed_to_open, app.appName, ), ), ) } } } private fun updateSingleApp(app: InstalledAppUi) { if (activeUpdates.containsKey(app.packageName)) { logger.debug("Update already in progress for ${app.packageName}") return } val job = viewModelScope.launch { try { updateAppState(app.packageName, UpdateState.CheckingUpdate) val latestRelease = try { appsRepository.getLatestRelease( owner = app.repoOwner, repo = app.repoName, ) } catch (e: Exception) { logger.error("Failed to fetch latest release: ${e.message}") throw IllegalStateException("Failed to fetch latest release: ${e.message}") } if (latestRelease == null) { throw IllegalStateException("No release found for ${app.appName}") } val installableAssets = latestRelease.assets.filter { asset -> installer.isAssetInstallable(asset.name) } if (installableAssets.isEmpty()) { throw IllegalStateException("No installable assets found for this platform") } val primaryAsset = installer.choosePrimaryAsset(installableAssets) ?: throw IllegalStateException("Could not determine primary asset") logger.debug( "Update: ${app.appName} from ${app.installedVersion} to ${latestRelease.tagName}, " + "asset: ${primaryAsset.name}", ) val latestAssetUrl = primaryAsset.downloadUrl val latestAssetName = primaryAsset.name val latestVersion = latestRelease.tagName val ext = latestAssetName.substringAfterLast('.', "").lowercase() installer.ensurePermissionsOrThrow(ext) val existingPath = downloader.getDownloadedFilePath(latestAssetName) if (existingPath != null) { val file = File(existingPath) try { val apkInfo = installer.getApkInfoExtractor().extractPackageInfo(existingPath) val normalizedExisting = apkInfo?.versionName?.removePrefix("v")?.removePrefix("V") ?: "" val normalizedLatest = latestVersion.removePrefix("v").removePrefix("V") if (normalizedExisting != normalizedLatest) { val deleted = file.delete() logger.debug("Deleted mismatched existing file ($normalizedExisting != $normalizedLatest): $deleted") } } catch (e: Exception) { logger.debug("Failed to extract APK info for existing file: ${e.message}") val deleted = file.delete() logger.debug("Deleted unextractable existing file: $deleted") } } updateAppState(app.packageName, UpdateState.Downloading) downloader.download(latestAssetUrl, latestAssetName).collect { progress -> updateAppProgress(app.packageName, progress.percent) } val filePath = downloader.getDownloadedFilePath(latestAssetName) ?: throw IllegalStateException("Downloaded file not found") val apkInfo = installer.getApkInfoExtractor().extractPackageInfo(filePath) ?: throw IllegalStateException("Failed to extract APK info") val currentApp = installedAppsRepository.getAppByPackage(app.packageName) if (currentApp != null) { installedAppsRepository.updateApp( currentApp.copy( isPendingInstall = true, latestVersion = latestVersion, latestAssetName = latestAssetName, latestAssetUrl = latestAssetUrl, latestVersionName = apkInfo.versionName, latestVersionCode = apkInfo.versionCode, ), ) } else { markPendingUpdate(app.toDomain()) } updateAppState(app.packageName, UpdateState.Installing) try { installer.install(filePath, ext) } catch (e: Exception) { installedAppsRepository.updatePendingStatus(app.packageName, false) throw e } updateAppState(app.packageName, UpdateState.Idle) logger.debug("Launched installer for ${app.appName} $latestVersion, waiting for system confirmation") } catch (e: CancellationException) { logger.debug("Update cancelled for ${app.packageName}") cleanupUpdate(app.packageName, app.latestAssetName) try { installedAppsRepository.updatePendingStatus(app.packageName, false) } catch (clearEx: Exception) { logger.error("Failed to clear pending status on cancellation: ${clearEx.message}") } updateAppState(app.packageName, UpdateState.Idle) throw e } catch (_: RateLimitException) { logger.debug("Rate limited during update for ${app.packageName}") try { installedAppsRepository.updatePendingStatus(app.packageName, false) } catch (clearEx: Exception) { logger.error("Failed to clear pending status on rate limit: ${clearEx.message}") } updateAppState(app.packageName, UpdateState.Idle) _events.send( AppsEvent.ShowError(getString(Res.string.rate_limit_exceeded)), ) } catch (e: Exception) { logger.error("Update failed for ${app.packageName}: ${e.message}") cleanupUpdate(app.packageName, app.latestAssetName) try { installedAppsRepository.updatePendingStatus(app.packageName, false) } catch (clearEx: Exception) { logger.error("Failed to clear pending status on error: ${clearEx.message}") } updateAppState( app.packageName, UpdateState.Error(e.message ?: "Update failed"), ) _events.send( AppsEvent.ShowError( getString( Res.string.failed_to_update, app.appName, e.message ?: "", ), ), ) } finally { activeUpdates.remove(app.packageName) } } activeUpdates[app.packageName] = job } private fun updateAllApps() { if (_state.value.isUpdatingAll) { logger.error("Update all already in progress") return } updateAllJob = viewModelScope.launch { try { _state.update { it.copy(isUpdatingAll = true) } val appsToUpdate = _state.value.apps.filter { it.installedApp.isUpdateAvailable && it.updateState !is UpdateState.Success } if (appsToUpdate.isEmpty()) { _events.send(AppsEvent.ShowError(getString(Res.string.no_updates_available))) return@launch } logger.debug("Starting update all for ${appsToUpdate.size} apps") appsToUpdate.forEachIndexed { index, appItem -> if (!isActive) { logger.debug("Update all cancelled") return@launch } _state.update { it.copy( updateAllProgress = UpdateAllProgress( current = index + 1, total = appsToUpdate.size, currentAppName = appItem.installedApp.appName, ), ) } logger.debug("Updating ${index + 1}/${appsToUpdate.size}: ${appItem.installedApp.appName}") updateSingleApp(appItem.installedApp) activeUpdates[appItem.installedApp.packageName]?.join() delay(1000) } logger.debug("Update all completed successfully") _events.send(AppsEvent.ShowSuccess(getString(Res.string.all_apps_updated_successfully))) } catch (_: CancellationException) { logger.debug("Update all cancelled") } catch (e: Exception) { logger.error("Update all failed: ${e.message}") _events.send( AppsEvent.ShowError( getString( Res.string.update_all_failed, arrayOf(e.message), ), ), ) } finally { _state.update { it.copy( isUpdatingAll = false, updateAllProgress = null, ) } updateAllJob = null } } } private fun cancelUpdate(packageName: String) { activeUpdates[packageName]?.cancel() activeUpdates.remove(packageName) val app = _state.value.apps.find { it.installedApp.packageName == packageName } app?.installedApp?.latestAssetName?.let { assetName -> viewModelScope.launch { cleanupUpdate(packageName, assetName) } } updateAppState(packageName, UpdateState.Idle) } private fun cancelAllUpdates() { updateAllJob?.cancel() updateAllJob = null activeUpdates.values.forEach { it.cancel() } activeUpdates.clear() viewModelScope.launch { _state.value.apps.forEach { appItem -> if (appItem.updateState != UpdateState.Idle && appItem.updateState != UpdateState.Success ) { appItem.installedApp.latestAssetName?.let { assetName -> cleanupUpdate(appItem.installedApp.packageName, assetName) } updateAppState(appItem.installedApp.packageName, UpdateState.Idle) } } } _state.update { it.copy( isUpdatingAll = false, updateAllProgress = null, ) } } private fun updateAppState( packageName: String, state: UpdateState, ) { _state.update { currentState -> currentState.copy( apps = currentState.apps .map { appItem -> if (appItem.installedApp.packageName == packageName) { appItem.copy( updateState = state, downloadProgress = if (state is UpdateState.Downloading) { appItem.downloadProgress } else { null }, error = if (state is UpdateState.Error) state.message else null, ) } else { appItem } }.toImmutableList(), ) } filterApps() } private fun updateAppProgress( packageName: String, progress: Int?, ) { _state.update { currentState -> currentState.copy( apps = currentState.apps .map { appItem -> if (appItem.installedApp.packageName == packageName) { appItem.copy(downloadProgress = progress) } else { appItem } }.toImmutableList(), ) } filterApps() } private suspend fun markPendingUpdate(app: InstalledApp) { installedAppsRepository.updatePendingStatus(app.packageName, true) logger.debug("Marked ${app.packageName} as pending install") } private suspend fun cleanupUpdate( packageName: String, assetName: String?, ) { try { if (assetName != null) { val deleted = downloader.cancelDownload(assetName) logger.debug("Cleanup for $packageName - file deleted: $deleted") } } catch (e: Exception) { logger.error("Cleanup failed for $packageName: ${e.message}") } } private fun openLinkSheet() { viewModelScope.launch { _state.update { it.copy( showLinkSheet = true, linkStep = LinkStep.PickApp, deviceApps = persistentListOf(), deviceAppSearchQuery = "", selectedDeviceApp = null, repoUrl = "", repoValidationError = null, fetchedRepoInfo = null, ) } try { val trackedPackages = appsRepository.getTrackedPackageNames() val deviceApps = appsRepository .getDeviceApps() .filter { it.packageName !in trackedPackages } .map { it.toUi() } .toImmutableList() _state.update { it.copy(deviceApps = deviceApps) } } catch (e: Exception) { logger.error("Failed to load device apps: ${e.message}") _events.send(AppsEvent.ShowError(getString(Res.string.failed_to_load_apps))) } } } private fun dismissLinkSheet() { _state.update { it.copy( showLinkSheet = false, linkStep = LinkStep.PickApp, deviceApps = persistentListOf(), deviceAppSearchQuery = "", selectedDeviceApp = null, repoUrl = "", repoValidationError = null, linkValidationStatus = null, linkInstallableAssets = persistentListOf(), linkSelectedAsset = null, linkDownloadProgress = null, fetchedRepoInfo = null, isValidatingRepo = false, ) } } private fun validateAndLinkRepo() { val selectedApp = _state.value.selectedDeviceApp ?: return val url = _state.value.repoUrl.trim() val parsed = parseGithubUrl(url) viewModelScope.launch { if (parsed == null) { _state.update { it.copy(repoValidationError = getString(Res.string.invalid_github_url)) } return@launch } val (owner, repo) = parsed _state.update { it.copy( isValidatingRepo = true, repoValidationError = null, linkValidationStatus = null, ) } try { _state.update { it.copy(linkValidationStatus = getString(Res.string.validating_repo)) } val repoInfo = appsRepository.fetchRepoInfo(owner, repo) if (repoInfo == null) { _state.update { it.copy( isValidatingRepo = false, linkValidationStatus = null, repoValidationError = getString(Res.string.repo_not_found, owner, repo), ) } return@launch } _state.update { it.copy( fetchedRepoInfo = repoInfo.toUi(), linkValidationStatus = getString(Res.string.checking_release), ) } val latestRelease = try { appsRepository.getLatestRelease(owner, repo) } catch (e: RateLimitException) { throw e } catch (e: Exception) { logger.debug("Could not fetch release for validation: ${e.message}") return@launch } if (latestRelease == null) { appsRepository.linkAppToRepo(selectedApp.toDomain(), repoInfo) _state.update { it.copy( isValidatingRepo = false, linkValidationStatus = null, showLinkSheet = false, ) } _events.send(AppsEvent.AppLinkedSuccessfully(selectedApp.appName)) _events.send( AppsEvent.ShowSuccess( getString( Res.string.app_linked_success, selectedApp.appName, repoInfo.owner, repoInfo.name, ), ), ) return@launch } val installableAssets = latestRelease .assets .filter { installer.isAssetInstallable(it.name) } .map { it.toUi() } .toImmutableList() if (installableAssets.isEmpty()) { appsRepository.linkAppToRepo(selectedApp.toDomain(), repoInfo) _state.update { it.copy( isValidatingRepo = false, linkValidationStatus = null, showLinkSheet = false, ) } _events.send(AppsEvent.AppLinkedSuccessfully(selectedApp.appName)) _events.send( AppsEvent.ShowSuccess( getString( Res.string.app_linked_success, selectedApp.appName, repoInfo.owner, repoInfo.name, ), ), ) return@launch } _state.update { it.copy( isValidatingRepo = false, linkValidationStatus = null, linkStep = LinkStep.PickAsset, linkInstallableAssets = installableAssets, ) } } catch (_: RateLimitException) { _state.update { it.copy( isValidatingRepo = false, linkValidationStatus = null, repoValidationError = getString(Res.string.rate_limit_try_again), ) } } catch (e: Exception) { logger.error("Failed to link app: ${e.message}") _state.update { it.copy( isValidatingRepo = false, linkValidationStatus = null, repoValidationError = getString(Res.string.failed_to_link, e.message ?: ""), ) } } } } private fun validateWithAsset(asset: GithubAssetUi) { val selectedApp = _state.value.selectedDeviceApp ?: return val repoInfo = _state.value.fetchedRepoInfo ?: return viewModelScope.launch { _state.update { it.copy( linkSelectedAsset = asset, linkDownloadProgress = 0, linkValidationStatus = getString(Res.string.downloading_for_verification), repoValidationError = null, ) } var filePath: String? = null try { downloader.download(asset.downloadUrl, asset.name).collect { progress -> _state.update { it.copy(linkDownloadProgress = progress.percent) } } filePath = downloader.getDownloadedFilePath(asset.name) if (filePath == null) { _state.update { it.copy( linkDownloadProgress = null, linkValidationStatus = null, repoValidationError = getString(Res.string.download_failed), ) } return@launch } _state.update { it.copy( linkDownloadProgress = 100, linkValidationStatus = getString(Res.string.verifying_signing_key), ) } val apkInfo = installer.getApkInfoExtractor().extractPackageInfo(filePath) if (apkInfo == null) { logger.debug("Could not extract APK info for validation, linking anyway") appsRepository.linkAppToRepo(selectedApp.toDomain(), repoInfo.toDomain()) _state.update { it.copy( linkDownloadProgress = null, linkValidationStatus = null, showLinkSheet = false, ) } _events.send(AppsEvent.AppLinkedSuccessfully(selectedApp.appName)) _events.send( AppsEvent.ShowSuccess( getString( Res.string.app_linked_success, selectedApp.appName, repoInfo.owner, repoInfo.name, ), ), ) return@launch } if (apkInfo.packageName != selectedApp.packageName) { _state.update { it.copy( linkDownloadProgress = null, linkValidationStatus = null, repoValidationError = getString( Res.string.package_name_mismatch, apkInfo.packageName, selectedApp.packageName, ), ) } return@launch } val deviceFingerprint = selectedApp.signingFingerprint val apkFingerprint = apkInfo.signingFingerprint if (deviceFingerprint != null && apkFingerprint != null && deviceFingerprint != apkFingerprint) { _state.update { it.copy( linkDownloadProgress = null, linkValidationStatus = null, repoValidationError = getString(Res.string.signing_key_mismatch_link), ) } return@launch } appsRepository.linkAppToRepo(selectedApp.toDomain(), repoInfo.toDomain()) _state.update { it.copy( linkDownloadProgress = null, linkValidationStatus = null, showLinkSheet = false, ) } _events.send(AppsEvent.AppLinkedSuccessfully(selectedApp.appName)) _events.send( AppsEvent.ShowSuccess( getString( Res.string.app_linked_success, selectedApp.appName, repoInfo.owner, repoInfo.name, ), ), ) } catch (_: RateLimitException) { _state.update { it.copy( linkDownloadProgress = null, linkValidationStatus = null, repoValidationError = getString(Res.string.rate_limit_try_again), ) } } catch (e: Exception) { logger.error("Failed to validate and link app: ${e.message}") _state.update { it.copy( linkDownloadProgress = null, linkValidationStatus = null, repoValidationError = getString(Res.string.failed_to_link, e.message ?: ""), ) } } finally { try { if (filePath != null) File(filePath).delete() } catch (_: Exception) { } } } } private fun parseGithubUrl(input: String): Pair? { val normalized = input .trim() .removePrefix("https://") .removePrefix("http://") .removePrefix("www.") .substringBefore("?") .substringBefore("#") .removeSuffix("/") val parts = normalized.split("/") if (parts.size < 3) return null if (!parts[0].equals("github.com", ignoreCase = true)) return null val owner = parts[1] val repo = parts[2] if (owner.isBlank() || repo.isBlank()) return null if (owner.length > 39 || repo.length > 100) return null return owner to repo } private fun exportApps() { viewModelScope.launch { _state.update { it.copy(isExporting = true) } try { val json = appsRepository.exportApps() val fileName = "github-store-apps-${System.currentTimeMillis()}.json" shareManager.shareFile(fileName, json) } catch (e: Exception) { logger.error("Export failed: ${e.message}") _events.send( AppsEvent.ShowError( getString( Res.string.export_failed, e.message ?: "", ), ), ) } finally { _state.update { it.copy(isExporting = false) } } } } private fun importAppsFromFile() { shareManager.pickFile("application/json") { content -> if (content != null) { viewModelScope.launch { importApps(content) } } } } private suspend fun importApps(json: String) { _state.update { it.copy(isImporting = true) } try { val result = appsRepository.importApps(json) _events.send(AppsEvent.ImportComplete(result)) _events.send( AppsEvent.ShowSuccess( getString(Res.string.imported_apps_summary, result.imported) + ( if (result.skipped > 0) { getString( Res.string.imported_skipped, result.skipped, ) } else { "" } ) + ( if (result.failed > 0) { getString( Res.string.imported_failed, result.failed, ) } else { "" } ), ), ) } catch (e: Exception) { logger.error("Import failed: ${e.message}") _events.send(AppsEvent.ShowError(getString(Res.string.import_failed, e.message ?: ""))) } finally { _state.update { it.copy(isImporting = false) } } } override fun onCleared() { super.onCleared() updateAllJob?.cancel() activeUpdates.values.forEach { it.cancel() } viewModelScope.launch { _state.value.apps.forEach { appItem -> if (appItem.updateState != UpdateState.Idle && appItem.updateState != UpdateState.Success ) { appItem.installedApp.latestAssetName?.let { assetName -> downloader.cancelDownload(assetName) } } } } } } ================================================ FILE: feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/LinkAppBottomSheet.kt ================================================ package zed.rainxch.apps.presentation.components import androidx.compose.animation.AnimatedContent import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.togetherWith import androidx.compose.foundation.background 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.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.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Search import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.rememberModalBottomSheetState 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.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import zed.rainxch.apps.presentation.AppsAction import zed.rainxch.apps.presentation.AppsState import zed.rainxch.apps.presentation.LinkStep import zed.rainxch.apps.presentation.model.DeviceAppUi import zed.rainxch.apps.presentation.model.GithubAssetUi import zed.rainxch.githubstore.core.presentation.res.* @OptIn(ExperimentalMaterial3Api::class) @Composable fun LinkAppBottomSheet( state: AppsState, onAction: (AppsAction) -> Unit, ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) ModalBottomSheet( onDismissRequest = { onAction(AppsAction.OnDismissLinkSheet) }, sheetState = sheetState, ) { AnimatedContent( targetState = state.linkStep, transitionSpec = { if (targetState.ordinal > initialState.ordinal) { (slideInHorizontally { it } + fadeIn()) togetherWith (slideOutHorizontally { -it } + fadeOut()) } else { (slideInHorizontally { -it } + fadeIn()) togetherWith (slideOutHorizontally { it } + fadeOut()) } }, label = "link_step", ) { step -> when (step) { LinkStep.PickApp -> PickAppStep( deviceApps = state.filteredDeviceApps, searchQuery = state.deviceAppSearchQuery, onSearchChange = { onAction(AppsAction.OnDeviceAppSearchChange(it)) }, onAppSelected = { onAction(AppsAction.OnDeviceAppSelected(it)) }, ) LinkStep.EnterUrl -> EnterUrlStep( selectedApp = state.selectedDeviceApp, repoUrl = state.repoUrl, isValidating = state.isValidatingRepo, validationError = state.repoValidationError, validationStatus = state.linkValidationStatus, onUrlChanged = { onAction(AppsAction.OnRepoUrlChanged(it)) }, onConfirm = { onAction(AppsAction.OnValidateAndLinkRepo) }, onBack = { onAction(AppsAction.OnBackToAppPicker) }, ) LinkStep.PickAsset -> PickAssetStep( assets = state.linkInstallableAssets, selectedAsset = state.linkSelectedAsset, downloadProgress = state.linkDownloadProgress, validationStatus = state.linkValidationStatus, validationError = state.repoValidationError, onAssetSelected = { onAction(AppsAction.OnLinkAssetSelected(it)) }, onBack = { onAction(AppsAction.OnBackToEnterUrl) }, ) } } } } @Composable private fun PickAppStep( deviceApps: List, searchQuery: String, onSearchChange: (String) -> Unit, onAppSelected: (DeviceAppUi) -> Unit, ) { Column( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), ) { Text( text = stringResource(Res.string.link_app_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, ) Spacer(Modifier.height(4.dp)) Text( text = stringResource(Res.string.pick_installed_app), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(Modifier.height(12.dp)) TextField( value = searchQuery, onValueChange = onSearchChange, modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(16.dp)), placeholder = { Text(stringResource(Res.string.search_apps_hint)) }, leadingIcon = { Icon( imageVector = Icons.Default.Search, contentDescription = null, ) }, singleLine = true, colors = TextFieldDefaults.colors( focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent, ), ) Spacer(Modifier.height(8.dp)) LazyColumn( modifier = Modifier .fillMaxWidth() .height(400.dp), ) { items( items = deviceApps, key = { it.packageName }, ) { app -> DeviceAppItem( app = app, onClick = { onAppSelected(app) }, ) HorizontalDivider( color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f), ) } if (deviceApps.isEmpty()) { item { Box( modifier = Modifier .fillMaxWidth() .padding(32.dp), contentAlignment = Alignment.Center, ) { Text( text = stringResource(Res.string.no_apps_found), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } } Spacer(Modifier.height(16.dp)) } } @Composable private fun DeviceAppItem( app: DeviceAppUi, onClick: () -> Unit, ) { Row( modifier = Modifier .fillMaxWidth() .clickable(onClick = onClick) .padding(vertical = 12.dp, horizontal = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { Column( modifier = Modifier.weight(1f), ) { Text( text = app.appName, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Text( text = app.packageName, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } Spacer(Modifier.width(8.dp)) app.versionName?.let { version -> Text( text = version, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.outline, ) } } } @Composable private fun EnterUrlStep( selectedApp: DeviceAppUi?, repoUrl: String, isValidating: Boolean, validationError: String?, validationStatus: String?, onUrlChanged: (String) -> Unit, onConfirm: () -> Unit, onBack: () -> Unit, ) { Column( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp) .padding(bottom = 24.dp), ) { Row( verticalAlignment = Alignment.CenterVertically, ) { IconButton(onClick = onBack) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null, ) } Text( text = stringResource(Res.string.link_app_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, ) } Spacer(Modifier.height(16.dp)) // Selected app info if (selectedApp != null) { Row( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(12.dp)) .padding(12.dp), verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f)) { Text( text = selectedApp.appName, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, ) Text( text = selectedApp.packageName, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } selectedApp.versionName?.let { Text( text = it, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, ) } } } Spacer(Modifier.height(16.dp)) OutlinedTextField( value = repoUrl, onValueChange = onUrlChanged, modifier = Modifier.fillMaxWidth(), label = { Text(stringResource(Res.string.enter_repo_url)) }, placeholder = { Text(stringResource(Res.string.repo_url_hint)) }, singleLine = true, isError = validationError != null, supportingText = validationError?.let { { Text(it, color = MaterialTheme.colorScheme.error) } }, shape = RoundedCornerShape(12.dp), ) Spacer(Modifier.height(20.dp)) FilledTonalButton( onClick = onConfirm, modifier = Modifier.fillMaxWidth(), enabled = repoUrl.isNotBlank() && !isValidating, shape = RoundedCornerShape(12.dp), ) { if (isValidating) { CircularProgressIndicator( modifier = Modifier.size(20.dp), strokeWidth = 2.dp, ) Spacer(Modifier.width(8.dp)) Text(stringResource(Res.string.validating_repo)) } else { Text( text = stringResource(Res.string.link_and_track), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, ) } } if (isValidating && validationStatus != null) { Spacer(Modifier.height(8.dp)) Text( text = validationStatus, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.fillMaxWidth(), ) } } } @Composable private fun PickAssetStep( assets: List, selectedAsset: GithubAssetUi?, downloadProgress: Int?, validationStatus: String?, validationError: String?, onAssetSelected: (GithubAssetUi) -> Unit, onBack: () -> Unit, ) { val isProcessing = selectedAsset != null Column( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp) .padding(bottom = 24.dp), ) { Row( verticalAlignment = Alignment.CenterVertically, ) { IconButton(onClick = onBack, enabled = !isProcessing) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null, ) } Text( text = stringResource(Res.string.select_asset_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, ) } Spacer(Modifier.height(4.dp)) Text( text = stringResource(Res.string.select_asset_description), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 4.dp), ) Spacer(Modifier.height(12.dp)) LazyColumn( modifier = Modifier .fillMaxWidth() .height(300.dp), ) { items( items = assets, key = { it.id }, ) { asset -> val isSelected = selectedAsset?.id == asset.id Row( modifier = Modifier .fillMaxWidth() .then( if (isSelected) { Modifier.background( MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f), RoundedCornerShape(8.dp), ) } else { Modifier }, ) .clickable(enabled = !isProcessing) { onAssetSelected(asset) } .padding(vertical = 12.dp, horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f)) { Text( text = asset.name, style = MaterialTheme.typography.bodyMedium, fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, maxLines = 2, overflow = TextOverflow.Ellipsis, ) Text( text = formatFileSize(asset.size), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } if (isSelected && downloadProgress != null) { Spacer(Modifier.width(8.dp)) CircularProgressIndicator( progress = { downloadProgress / 100f }, modifier = Modifier.size(24.dp), strokeWidth = 2.dp, ) Spacer(Modifier.width(4.dp)) Text( text = "$downloadProgress%", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary, ) } } HorizontalDivider( color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f), ) } } if (validationStatus != null) { Spacer(Modifier.height(8.dp)) Text( text = validationStatus, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } if (validationError != null) { Spacer(Modifier.height(8.dp)) Text( text = validationError, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, ) } } } private fun formatFileSize(bytes: Long): String = when { bytes >= 1_073_741_824 -> "%.1f GB".format(bytes / 1_073_741_824.0) bytes >= 1_048_576 -> "%.1f MB".format(bytes / 1_048_576.0) bytes >= 1_024 -> "%.1f KB".format(bytes / 1_024.0) else -> "$bytes B" } ================================================ FILE: feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/mappers/DeviceAppMapper.kt ================================================ package zed.rainxch.apps.presentation.mappers import zed.rainxch.apps.presentation.model.DeviceAppUi import zed.rainxch.core.domain.model.DeviceApp fun DeviceApp.toUi() : DeviceAppUi { return DeviceAppUi( packageName = packageName, appName = appName, versionName = versionName, versionCode = versionCode, signingFingerprint = signingFingerprint ) } fun DeviceAppUi.toDomain() : DeviceApp { return DeviceApp( packageName = packageName, appName = appName, versionName = versionName, versionCode = versionCode, signingFingerprint = signingFingerprint ) } ================================================ FILE: feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/mappers/GithubAssetMapper.kt ================================================ package zed.rainxch.apps.presentation.mappers import zed.rainxch.apps.presentation.model.GithubAssetUi import zed.rainxch.apps.presentation.model.GithubUserUi import zed.rainxch.core.domain.model.GithubAsset fun GithubAsset.toUi(): GithubAssetUi { return GithubAssetUi( id = id, name = name, contentType = contentType, size = size, downloadUrl = downloadUrl, uploader = GithubUserUi( id = uploader.id, login = uploader.login, avatarUrl = uploader.avatarUrl, htmlUrl = uploader.htmlUrl, ), ) } ================================================ FILE: feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/mappers/GithubRepoInfoMapper.kt ================================================ package zed.rainxch.apps.presentation.mappers import zed.rainxch.apps.domain.model.GithubRepoInfo import zed.rainxch.apps.presentation.model.GithubRepoInfoUi fun GithubRepoInfo.toUi(): GithubRepoInfoUi = GithubRepoInfoUi( id = id, name = name, owner = owner, ownerAvatarUrl = ownerAvatarUrl, description = description, language = language, htmlUrl = htmlUrl, latestReleaseTag = latestReleaseTag, ) fun GithubRepoInfoUi.toDomain(): GithubRepoInfo = GithubRepoInfo( id = id, name = name, owner = owner, ownerAvatarUrl = ownerAvatarUrl, description = description, language = language, htmlUrl = htmlUrl, latestReleaseTag = latestReleaseTag, ) ================================================ FILE: feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/mappers/InstalledAppMapper.kt ================================================ package zed.rainxch.apps.presentation.mappers import zed.rainxch.apps.presentation.model.InstalledAppUi import zed.rainxch.core.domain.model.InstalledApp fun InstalledApp.toUi(): InstalledAppUi = InstalledAppUi( repoId = repoId, repoName = repoName, repoOwner = repoOwner, repoOwnerAvatarUrl = repoOwnerAvatarUrl, repoDescription = repoDescription, primaryLanguage = primaryLanguage, repoUrl = repoUrl, installedVersion = installedVersion, installedAssetName = installedAssetName, installedAssetUrl = installedAssetUrl, latestVersion = latestVersion, latestAssetName = latestAssetName, latestAssetUrl = latestAssetUrl, latestAssetSize = latestAssetSize, installSource = installSource, installedAt = installedAt, lastCheckedAt = lastCheckedAt, lastUpdatedAt = lastUpdatedAt, isUpdateAvailable = isUpdateAvailable, updateCheckEnabled = updateCheckEnabled, releaseNotes = releaseNotes, systemArchitecture = systemArchitecture, fileExtension = fileExtension, isPendingInstall = isPendingInstall, installedVersionName = installedVersionName, installedVersionCode = installedVersionCode, latestVersionName = latestVersionName, latestVersionCode = latestVersionCode, packageName = packageName, appName = appName, signingFingerprint = signingFingerprint, ) fun InstalledAppUi.toDomain(): InstalledApp = InstalledApp( repoId = repoId, repoName = repoName, repoOwner = repoOwner, repoOwnerAvatarUrl = repoOwnerAvatarUrl, repoDescription = repoDescription, primaryLanguage = primaryLanguage, repoUrl = repoUrl, installedVersion = installedVersion, installedAssetName = installedAssetName, installedAssetUrl = installedAssetUrl, latestVersion = latestVersion, latestAssetName = latestAssetName, latestAssetUrl = latestAssetUrl, latestAssetSize = latestAssetSize, installSource = installSource, installedAt = installedAt, lastCheckedAt = lastCheckedAt, lastUpdatedAt = lastUpdatedAt, isUpdateAvailable = isUpdateAvailable, updateCheckEnabled = updateCheckEnabled, releaseNotes = releaseNotes, systemArchitecture = systemArchitecture, fileExtension = fileExtension, isPendingInstall = isPendingInstall, installedVersionName = installedVersionName, installedVersionCode = installedVersionCode, latestVersionName = latestVersionName, latestVersionCode = latestVersionCode, packageName = packageName, appName = appName, signingFingerprint = signingFingerprint, ) ================================================ FILE: feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/model/AppItem.kt ================================================ package zed.rainxch.apps.presentation.model data class AppItem( val installedApp: InstalledAppUi, val updateState: UpdateState = UpdateState.Idle, val downloadProgress: Int? = null, val error: String? = null, ) ================================================ FILE: feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/model/DeviceAppUi.kt ================================================ package zed.rainxch.apps.presentation.model data class DeviceAppUi( val packageName: String, val appName: String, val versionName: String?, val versionCode: Long, val signingFingerprint: String?, ) ================================================ FILE: feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/model/GithubAssetUi.kt ================================================ package zed.rainxch.apps.presentation.model data class GithubAssetUi( val id: Long, val name: String, val contentType: String, val size: Long, val downloadUrl: String, val uploader: GithubUserUi, ) ================================================ FILE: feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/model/GithubRepoInfoUi.kt ================================================ package zed.rainxch.apps.presentation.model data class GithubRepoInfoUi( val id: Long, val name: String, val owner: String, val ownerAvatarUrl: String, val description: String?, val language: String?, val htmlUrl: String, val latestReleaseTag: String?, ) ================================================ FILE: feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/model/GithubUserUi.kt ================================================ package zed.rainxch.apps.presentation.model import kotlinx.serialization.Serializable data class GithubUserUi( val id: Long, val login: String, val avatarUrl: String, val htmlUrl: String, ) ================================================ FILE: feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/model/InstalledAppUi.kt ================================================ package zed.rainxch.apps.presentation.model import zed.rainxch.core.domain.model.InstallSource data class InstalledAppUi( val packageName: String, val repoId: Long, val repoName: String, val repoOwner: String, val repoOwnerAvatarUrl: String, val repoDescription: String?, val primaryLanguage: String?, val repoUrl: String, val installedVersion: String, val installedAssetName: String?, val installedAssetUrl: String?, val latestVersion: String?, val latestAssetName: String?, val latestAssetUrl: String?, val latestAssetSize: Long?, val appName: String, val installSource: InstallSource, val installedAt: Long, val lastCheckedAt: Long, val lastUpdatedAt: Long, val isUpdateAvailable: Boolean, val signingFingerprint: String?, val updateCheckEnabled: Boolean = true, val releaseNotes: String? = "", val systemArchitecture: String, val fileExtension: String, val isPendingInstall: Boolean = false, val installedVersionName: String? = null, val installedVersionCode: Long = 0L, val latestVersionName: String? = null, val latestVersionCode: Long? = null, ) ================================================ FILE: feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/model/UpdateAllProgress.kt ================================================ package zed.rainxch.apps.presentation.model data class UpdateAllProgress( val current: Int, val total: Int, val currentAppName: String, ) ================================================ FILE: feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/model/UpdateState.kt ================================================ package zed.rainxch.apps.presentation.model sealed class UpdateState { data object Idle : UpdateState() data object CheckingUpdate : UpdateState() data object Downloading : UpdateState() data object Installing : UpdateState() data object Success : UpdateState() data class Error( val message: String, ) : UpdateState() } ================================================ FILE: feature/auth/CLAUDE.md ================================================ # CLAUDE.md - Auth Feature ## Purpose GitHub OAuth authentication using the **device flow**. Users authenticate by visiting a URL and entering a code displayed in the app. No browser redirect needed, making it suitable for both Android and Desktop. ## Module Structure ``` feature/auth/ ├── domain/ │ └── repository/AuthenticationRepository.kt # Device flow interface ├── data/ │ ├── di/SharedModule.kt # Koin: authModule │ ├── repository/AuthenticationRepositoryImpl.kt # OAuth device flow implementation │ └── network/GitHubAuthApi.kt # GitHub OAuth API endpoints └── presentation/ ├── AuthenticationViewModel.kt # Manages device flow lifecycle ├── AuthenticationState.kt # Code, URL, loading, error ├── AuthenticationAction.kt # StartAuth, Cancel, etc. ├── AuthenticationEvent.kt # One-off events ├── AuthenticationRoot.kt # UI: displays code + verification URL └── components/ # Auth UI components ``` ## Key Interfaces ```kotlin interface AuthenticationRepository { val accessTokenFlow: Flow suspend fun startDeviceFlow(): GithubDeviceStart suspend fun awaitDeviceToken(start: GithubDeviceStart): GithubDeviceTokenSuccess } ``` ## Navigation Route: `GithubStoreGraph.AuthenticationScreen` (data object, no params) ## Implementation Notes - Uses GitHub's [device authorization flow](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#device-flow) - `startDeviceFlow()` returns a user code + verification URL to display - `awaitDeviceToken()` polls GitHub until the user completes verification - Token is stored via `TokenStore` in core/data (DataStore-backed) - `GITHUB_CLIENT_ID` must be set in `local.properties` for builds - `accessTokenFlow` is observed app-wide by `MainViewModel` for auth state ================================================ FILE: feature/auth/data/.gitignore ================================================ /build ================================================ FILE: feature/auth/data/build.gradle.kts ================================================ plugins { alias(libs.plugins.convention.kmp.library) alias(libs.plugins.convention.buildkonfig) } kotlin { sourceSets { commonMain { dependencies { implementation(libs.kotlin.stdlib) implementation(projects.core.domain) implementation(projects.core.data) implementation(projects.feature.auth.domain) implementation(libs.bundles.ktor.common) implementation(libs.bundles.koin.common) } } androidMain { dependencies { } } jvmMain { dependencies { } } } } ================================================ FILE: feature/auth/data/src/androidMain/AndroidManifest.xml ================================================ ================================================ FILE: feature/auth/data/src/commonMain/kotlin/zed/rainxch/auth/data/di/SharedModule.kt ================================================ package zed.rainxch.auth.data.di import org.koin.dsl.module import zed.rainxch.auth.data.repository.AuthenticationRepositoryImpl import zed.rainxch.auth.domain.repository.AuthenticationRepository val authModule = module { single { AuthenticationRepositoryImpl( tokenStore = get(), logger = get(), ) } } ================================================ FILE: feature/auth/data/src/commonMain/kotlin/zed/rainxch/auth/data/network/GitHubAuthApi.kt ================================================ package zed.rainxch.auth.data.network import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.plugins.HttpRequestRetry import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.timeout import io.ktor.client.request.accept import io.ktor.client.request.forms.FormDataContent import io.ktor.client.request.post import io.ktor.client.request.setBody import io.ktor.client.statement.bodyAsText import io.ktor.http.ContentType import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import io.ktor.http.Parameters import io.ktor.http.contentType import io.ktor.serialization.kotlinx.json.json import kotlinx.coroutines.delay import kotlinx.serialization.json.Json import zed.rainxch.core.data.dto.GithubDeviceStartDto import zed.rainxch.core.data.dto.GithubDeviceTokenErrorDto import zed.rainxch.core.data.dto.GithubDeviceTokenSuccessDto object GitHubAuthApi { private val json = Json { ignoreUnknownKeys = true isLenient = true } val http by lazy { HttpClient { install(ContentNegotiation) { json(json) } install(HttpTimeout) { requestTimeoutMillis = 60_000 connectTimeoutMillis = 30_000 socketTimeoutMillis = 30_000 } install(HttpRequestRetry) { retryOnServerErrors(maxRetries = 2) retryOnException(maxRetries = 2, retryOnTimeout = true) exponentialDelay() } } } suspend fun startDeviceFlow(clientId: String): GithubDeviceStartDto = withRetry(maxAttempts = 3, initialDelay = 1000) { val res = http.post("https://github.com/login/device/code") { accept(ContentType.Application.Json) headers.append(HttpHeaders.UserAgent, "GithubStore/1.0 (DeviceFlow)") contentType(ContentType.Application.FormUrlEncoded) setBody( FormDataContent( Parameters.build { append("client_id", clientId) }, ), ) } val status = res.status val text = res.bodyAsText() if (status !in HttpStatusCode.OK..HttpStatusCode.MultipleChoices) { error( buildString { append("GitHub device/code HTTP ") append(status.value) append(" ") append(status.description) append(". Body: ") append(text.take(300)) }, ) } try { json.decodeFromString(GithubDeviceStartDto.serializer(), text) } catch (_: Throwable) { try { val err = json.decodeFromString(GithubDeviceTokenErrorDto.serializer(), text) error("${err.error}: ${err.errorDescription ?: ""}".trim()) } catch (_: Throwable) { error("Unexpected response from GitHub: $text") } } } suspend fun pollDeviceToken( clientId: String, deviceCode: String, ): Result { return try { val res = http.post("https://github.com/login/oauth/access_token") { accept(ContentType.Application.Json) headers.append(HttpHeaders.UserAgent, "GithubStore/1.0 (DeviceFlow)") contentType(ContentType.Application.FormUrlEncoded) timeout { socketTimeoutMillis = 30_000 } setBody( FormDataContent( Parameters.build { append("client_id", clientId) append("device_code", deviceCode) append("grant_type", "urn:ietf:params:oauth:grant-type:device_code") }, ), ) } val status = res.status val text = res.body() if (status !in HttpStatusCode.OK..HttpStatusCode.MultipleChoices) { return Result.failure( IllegalStateException( "GitHub access_token HTTP ${status.value} ${status.description}", ), ) } try { val ok = json.decodeFromString(GithubDeviceTokenSuccessDto.serializer(), text) Result.success(ok) } catch (_: Throwable) { val err = json.decodeFromString(GithubDeviceTokenErrorDto.serializer(), text) val message = buildString { append(err.error) val desc = err.errorDescription if (!desc.isNullOrBlank()) { append(": ") append(desc) } } Result.failure(IllegalStateException(message)) } } catch (e: Exception) { Result.failure(e) } } private suspend fun withRetry( maxAttempts: Int = 3, initialDelay: Long = 1000, maxDelay: Long = 5000, factor: Double = 2.0, block: suspend () -> T, ): T { var currentDelay = initialDelay repeat(maxAttempts - 1) { attempt -> try { return block() } catch (e: Exception) { println("⚠️ Attempt ${attempt + 1} failed: ${e.message}") if (attempt == maxAttempts - 2) throw e } delay(currentDelay) currentDelay = (currentDelay * factor).toLong().coerceAtMost(maxDelay) } return block() } } ================================================ FILE: feature/auth/data/src/commonMain/kotlin/zed/rainxch/auth/data/repository/AuthenticationRepositoryImpl.kt ================================================ package zed.rainxch.auth.data.repository import kotlinx.coroutines.* import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext import zed.rainxch.auth.data.network.GitHubAuthApi import zed.rainxch.auth.domain.repository.AuthenticationRepository import zed.rainxch.core.data.data_source.TokenStore import zed.rainxch.core.data.mappers.toData import zed.rainxch.core.data.mappers.toDomain import zed.rainxch.core.domain.logging.GitHubStoreLogger import zed.rainxch.core.domain.model.GithubDeviceStart import zed.rainxch.core.domain.model.GithubDeviceTokenSuccess import zed.rainxch.feature.auth.data.BuildKonfig import java.util.concurrent.TimeoutException class AuthenticationRepositoryImpl( private val tokenStore: TokenStore, private val logger: GitHubStoreLogger, ) : AuthenticationRepository { override val accessTokenFlow: Flow get() = tokenStore.tokenFlow().map { it?.accessToken } override suspend fun startDeviceFlow(): GithubDeviceStart = withContext(Dispatchers.IO) { val clientId = BuildKonfig.GITHUB_CLIENT_ID require(clientId.isNotBlank()) { "Missing GitHub CLIENT_ID. Add GITHUB_CLIENT_ID to local.properties." } try { val result = GitHubAuthApi.startDeviceFlow(clientId) logger.debug("✅ Device flow started. User code: ${result.userCode}") result.toDomain() } catch (e: Exception) { logger.debug("❌ Failed to start device flow: ${e.message}") throw Exception( "Failed to start GitHub authentication. " + "Please check your internet connection and try again.", e, ) } } override suspend fun awaitDeviceToken(start: GithubDeviceStart): GithubDeviceTokenSuccess = withContext(Dispatchers.IO) { val clientId = BuildKonfig.GITHUB_CLIENT_ID val timeoutMs = start.expiresInSec * 1000L val startTime = System.currentTimeMillis() val initialJitter = (0..2000).random().toLong() delay(initialJitter) var pollingInterval = (start.intervalSec.coerceAtLeast(5)) * 1000L var consecutiveNetworkErrors = 0 var consecutiveUnknownErrors = 0 var slowDownCount = 0 logger.debug("⏱️ Polling started. Timeout: ${start.expiresInSec}s, Interval: ${start.intervalSec}s") while (isActive) { if (System.currentTimeMillis() - startTime >= timeoutMs) { throw TimeoutException( "Authentication timed out after ${start.expiresInSec} seconds. Please try again.", ) } try { val res = GitHubAuthApi.pollDeviceToken(clientId, start.deviceCode) val success = res.getOrNull()?.toDomain() if (success != null) { logger.debug("✅ Token received! Saving...") saveTokenWithVerification(success) logger.debug("✅ Token saved and verified successfully!") return@withContext success } val error = res.exceptionOrNull() val errorMsg = (error?.message ?: "").lowercase() when { "authorization_pending" in errorMsg -> { consecutiveNetworkErrors = 0 consecutiveUnknownErrors = 0 if (slowDownCount > 0) slowDownCount-- logger.debug("📡 Waiting for user authorization...") delay(pollingInterval + (0..1000).random()) } "slow_down" in errorMsg -> { consecutiveNetworkErrors = 0 consecutiveUnknownErrors = 0 slowDownCount++ pollingInterval += 5000 logger.debug("⚠️ Rate limited. New interval: ${pollingInterval}ms (slowdown #$slowDownCount)") if (slowDownCount > 10) { throw Exception( "GitHub is experiencing high traffic. Please wait a few minutes and try again.", ) } delay(pollingInterval + (0..3000).random()) } "access_denied" in errorMsg -> { throw Exception( "Authentication was denied. Please try again if this was a mistake.", ) } "expired_token" in errorMsg || "expired_device_code" in errorMsg || "token_expired" in errorMsg -> { throw Exception( "Authorization code expired. Please try again.", ) } "bad_verification_code" in errorMsg || "incorrect_device_code" in errorMsg -> { throw Exception( "Invalid verification code. Please restart authentication.", ) } isNetworkError(errorMsg) -> { consecutiveNetworkErrors++ consecutiveUnknownErrors = 0 logger.debug("⚠️ Network error ($consecutiveNetworkErrors/8): $errorMsg") if (consecutiveNetworkErrors >= 8) { throw Exception( "Network connection is unstable. Please check your connection and try again.", ) } val backoff = minOf( pollingInterval * (1 + consecutiveNetworkErrors), 30_000L, ) delay(backoff) } else -> { consecutiveUnknownErrors++ logger.debug("⚠️ Unknown error ($consecutiveUnknownErrors/5): $errorMsg") if (consecutiveUnknownErrors >= 5) { throw Exception( "Authentication failed: ${error?.message ?: "Unknown error"}", ) } val backoff = minOf( pollingInterval * (1 + consecutiveUnknownErrors / 2), 20_000L, ) delay(backoff) } } } catch (e: CancellationException) { throw e } catch (e: TimeoutException) { throw e } catch (e: Exception) { consecutiveUnknownErrors++ logger.debug("❌ Unexpected error ($consecutiveUnknownErrors/5): ${e.message}") if (consecutiveUnknownErrors >= 5) { throw Exception( "Authentication failed after multiple errors: ${e.message}", e, ) } delay(minOf(pollingInterval * 2, 15_000L)) } } throw CancellationException("Authentication was cancelled") } private suspend fun saveTokenWithVerification(token: GithubDeviceTokenSuccess) { repeat(5) { attempt -> try { tokenStore.save(token.toData()) delay(100) val saved = tokenStore.currentToken() if (saved?.accessToken == token.accessToken) { return } else { logger.debug("⚠️ Token verification failed (attempt ${attempt + 1}/5)") if (attempt == 4) { throw Exception("Token was not persisted correctly after 5 attempts") } } } catch (e: CancellationException) { throw e } catch (e: Exception) { logger.debug("⚠️ Token save failed (attempt ${attempt + 1}/5): ${e.message}") if (attempt == 4) { throw Exception("Failed to save authentication token: ${e.message}", e) } delay(500L * (attempt + 1)) } } } private fun isNetworkError(errorMsg: String): Boolean = errorMsg.contains("unable to resolve") || errorMsg.contains("no address") || errorMsg.contains("failed to connect") || errorMsg.contains("connection refused") || errorMsg.contains("network is unreachable") || errorMsg.contains("timeout") || errorMsg.contains("timed out") || errorMsg.contains("connection reset") || errorMsg.contains("broken pipe") || errorMsg.contains("host unreachable") || errorMsg.contains("network error") } ================================================ FILE: feature/auth/domain/.gitignore ================================================ /build ================================================ FILE: feature/auth/domain/build.gradle.kts ================================================ plugins { alias(libs.plugins.convention.kmp.library) } kotlin { sourceSets { commonMain { dependencies { implementation(libs.kotlin.stdlib) implementation(projects.core.domain) implementation(libs.kotlinx.coroutines.core) } } androidMain { dependencies { } } jvmMain { dependencies { } } } } ================================================ FILE: feature/auth/domain/src/androidMain/AndroidManifest.xml ================================================ ================================================ FILE: feature/auth/domain/src/commonMain/kotlin/zed/rainxch/auth/domain/repository/AuthenticationRepository.kt ================================================ package zed.rainxch.auth.domain.repository import kotlinx.coroutines.flow.Flow import zed.rainxch.core.domain.model.GithubDeviceStart import zed.rainxch.core.domain.model.GithubDeviceTokenSuccess interface AuthenticationRepository { val accessTokenFlow: Flow suspend fun startDeviceFlow(): GithubDeviceStart suspend fun awaitDeviceToken(start: GithubDeviceStart): GithubDeviceTokenSuccess } ================================================ FILE: feature/auth/presentation/.gitignore ================================================ /build ================================================ FILE: feature/auth/presentation/build.gradle.kts ================================================ plugins { alias(libs.plugins.convention.cmp.feature) } kotlin { sourceSets { commonMain { dependencies { implementation(libs.kotlin.stdlib) implementation(projects.core.domain) implementation(projects.core.presentation) implementation(projects.feature.auth.domain) implementation(libs.androidx.compose.ui.tooling.preview) implementation(compose.components.resources) } } androidMain { dependencies { } } jvmMain { dependencies { } } } } ================================================ FILE: feature/auth/presentation/src/androidMain/AndroidManifest.xml ================================================ ================================================ FILE: feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationAction.kt ================================================ package zed.rainxch.auth.presentation import zed.rainxch.auth.presentation.model.GithubDeviceStartUi sealed interface AuthenticationAction { data object StartLogin : AuthenticationAction data class CopyCode( val start: GithubDeviceStartUi, ) : AuthenticationAction data class OpenGitHub( val start: GithubDeviceStartUi, ) : AuthenticationAction data object MarkLoggedOut : AuthenticationAction data object MarkLoggedIn : AuthenticationAction data class OnInfo( val message: String, ) : AuthenticationAction data object SkipLogin : AuthenticationAction } ================================================ FILE: feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationEvents.kt ================================================ package zed.rainxch.auth.presentation sealed interface AuthenticationEvents { data object OnNavigateToMain : AuthenticationEvents } ================================================ FILE: feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationRoot.kt ================================================ package zed.rainxch.auth.presentation import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image 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.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.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.DoneAll import androidx.compose.material.icons.filled.OpenWith import androidx.compose.material3.Card import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton 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.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import zed.rainxch.auth.presentation.model.AuthLoginState import zed.rainxch.auth.presentation.model.GithubDeviceStartUi import zed.rainxch.core.presentation.components.GithubStoreButton import zed.rainxch.core.presentation.theme.GithubStoreTheme import zed.rainxch.core.presentation.utils.ObserveAsEvents import zed.rainxch.githubstore.core.presentation.res.Res import zed.rainxch.githubstore.core.presentation.res.app_icon import zed.rainxch.githubstore.core.presentation.res.auth_code_expires_in import zed.rainxch.githubstore.core.presentation.res.auth_error_with_message import zed.rainxch.githubstore.core.presentation.res.continue_as_guest import zed.rainxch.githubstore.core.presentation.res.copy_code import zed.rainxch.githubstore.core.presentation.res.enter_code_on_github import zed.rainxch.githubstore.core.presentation.res.ic_github import zed.rainxch.githubstore.core.presentation.res.more_requests import zed.rainxch.githubstore.core.presentation.res.more_requests_description import zed.rainxch.githubstore.core.presentation.res.open_github import zed.rainxch.githubstore.core.presentation.res.redirecting_message import zed.rainxch.githubstore.core.presentation.res.sign_in_with_github import zed.rainxch.githubstore.core.presentation.res.signed_in import zed.rainxch.githubstore.core.presentation.res.try_again import zed.rainxch.githubstore.core.presentation.res.unlock_full_experience import zed.rainxch.githubstore.core.presentation.res.waiting_for_authorization @Composable fun AuthenticationRoot( onNavigateToHome: () -> Unit, viewModel: AuthenticationViewModel = koinViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() ObserveAsEvents(viewModel.events) { event -> when (event) { AuthenticationEvents.OnNavigateToMain -> { onNavigateToHome() } } } AuthenticationScreen( state = state, onAction = viewModel::onAction, ) } @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun AuthenticationScreen( state: AuthenticationState, onAction: (AuthenticationAction) -> Unit, ) { Scaffold( modifier = Modifier.fillMaxSize(), containerColor = MaterialTheme.colorScheme.background, ) { innerPadding -> Column( modifier = Modifier .fillMaxSize() .padding(innerPadding) .padding(vertical = 32.dp, horizontal = 16.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { Image( painter = painterResource(Res.drawable.app_icon), contentDescription = null, modifier = Modifier .size(150.dp) .clip(RoundedCornerShape(32.dp)), contentScale = ContentScale.Crop, ) Spacer(Modifier.height(16.dp)) when (val authState = state.loginState) { is AuthLoginState.LoggedOut -> { StateLoggedOut( onAction = onAction, ) } is AuthLoginState.DevicePrompt -> { StateDevicePrompt( state = state, authState = authState, onAction = onAction, ) } is AuthLoginState.Pending -> { CircularWavyProgressIndicator() Spacer(Modifier.height(12.dp)) Text( text = stringResource(Res.string.waiting_for_authorization), ) } is AuthLoginState.LoggedIn -> { Text( text = stringResource(Res.string.signed_in), style = MaterialTheme.typography.titleLarge, ) Spacer(Modifier.height(8.dp)) Text( text = stringResource(Res.string.redirecting_message), ) } is AuthLoginState.Error -> { Spacer(Modifier.weight(1f)) Text( text = stringResource( Res.string.auth_error_with_message, authState.message, ), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.error, ) authState.recoveryHint?.let { hint -> Spacer(Modifier.height(8.dp)) Text( text = hint, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary, textAlign = TextAlign.Center, ) } Spacer(Modifier.height(12.dp)) GithubStoreButton( text = stringResource(Res.string.try_again), onClick = { onAction(AuthenticationAction.StartLogin) }, modifier = Modifier.fillMaxWidth(.7f), ) Spacer(Modifier.height(8.dp)) TextButton( onClick = { onAction(AuthenticationAction.SkipLogin) }, ) { Text( text = stringResource(Res.string.continue_as_guest), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.outline, ) } Spacer(Modifier.weight(2f)) } } } } } @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun StateDevicePrompt( state: AuthenticationState, authState: AuthLoginState.DevicePrompt, onAction: (AuthenticationAction) -> Unit, ) { Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, ) { Spacer(Modifier.weight(1f)) Text( text = stringResource(Res.string.enter_code_on_github), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onBackground, ) Spacer(Modifier.height(8.dp)) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { Text( text = authState.start.userCode, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, ) IconButton( shapes = IconButtonDefaults.shapes(), onClick = { onAction(AuthenticationAction.CopyCode(authState.start)) }, colors = IconButtonDefaults.iconButtonColors( containerColor = MaterialTheme.colorScheme.primaryContainer, contentColor = MaterialTheme.colorScheme.onPrimaryContainer, ), ) { Icon( imageVector = if (state.copied) { Icons.Default.DoneAll } else { Icons.Default.ContentCopy }, contentDescription = stringResource(Res.string.copy_code), ) } } Spacer(Modifier.height(16.dp)) state.info?.let { info -> Text( text = info, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.primary, ) } Spacer(Modifier.height(16.dp)) if (authState.remainingSeconds > 0) { val minutes = authState.remainingSeconds / 60 val seconds = authState.remainingSeconds % 60 val formatted = remember(minutes, seconds) { "%02d:%02d".format(minutes, seconds) } Text( text = stringResource(Res.string.auth_code_expires_in, formatted), style = MaterialTheme.typography.bodyMedium, color = if (authState.remainingSeconds < 60) { MaterialTheme.colorScheme.error } else { MaterialTheme.colorScheme.outline }, ) Spacer(Modifier.height(16.dp)) } GithubStoreButton( text = stringResource(Res.string.open_github), onClick = { onAction(AuthenticationAction.OpenGitHub(authState.start)) }, icon = { Icon( painter = painterResource(Res.drawable.ic_github), contentDescription = null, modifier = Modifier.size(24.dp), ) }, ) Spacer(Modifier.weight(2f)) } } @Composable fun StateLoggedOut(onAction: (AuthenticationAction) -> Unit) { Column( horizontalAlignment = Alignment.CenterHorizontally, ) { Text( text = stringResource(Res.string.unlock_full_experience), style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onBackground, textAlign = TextAlign.Center, ) Spacer(Modifier.height(32.dp)) Card( border = BorderStroke(1.dp, MaterialTheme.colorScheme.secondary), modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(24.dp), ) { Column( modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp), ) { Icon( imageVector = Icons.Default.OpenWith, contentDescription = null, modifier = Modifier.size(24.dp), tint = MaterialTheme.colorScheme.primary, ) Spacer(Modifier.height(4.dp)) Text( text = stringResource(Res.string.more_requests), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface, ) Text( text = stringResource(Res.string.more_requests_description), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, ) } } Spacer(Modifier.weight(1f)) GithubStoreButton( text = stringResource(Res.string.sign_in_with_github), onClick = { onAction(AuthenticationAction.StartLogin) }, icon = { Icon( painter = painterResource(Res.drawable.ic_github), contentDescription = null, modifier = Modifier.size(24.dp), ) }, modifier = Modifier.fillMaxWidth(), ) Spacer(Modifier.height(8.dp)) TextButton( onClick = { onAction(AuthenticationAction.SkipLogin) }, ) { Text( text = stringResource(Res.string.continue_as_guest), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.outline, ) } } } @Preview @Composable private fun Preview() { GithubStoreTheme { AuthenticationScreen( state = AuthenticationState( loginState = AuthLoginState.Error( message = "Halo", ), ), onAction = {}, ) } } @Preview @Composable private fun Preview1() { GithubStoreTheme { AuthenticationScreen( state = AuthenticationState( loginState = AuthLoginState.LoggedOut, ), onAction = {}, ) } } @Preview @Composable private fun Preview2() { GithubStoreTheme { AuthenticationScreen( state = AuthenticationState( loginState = AuthLoginState.DevicePrompt( GithubDeviceStartUi( deviceCode = "", userCode = "2102-UHHUF", verificationUri = "", expiresInSec = 10, ), ), ), onAction = {}, ) } } ================================================ FILE: feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationState.kt ================================================ package zed.rainxch.auth.presentation import zed.rainxch.auth.presentation.model.AuthLoginState data class AuthenticationState( val loginState: AuthLoginState = AuthLoginState.LoggedOut, val copied: Boolean = false, val info: String? = null, ) ================================================ FILE: feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationViewModel.kt ================================================ package zed.rainxch.auth.presentation import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jetbrains.compose.resources.getString import zed.rainxch.auth.domain.repository.AuthenticationRepository import zed.rainxch.auth.presentation.mapper.toUi import zed.rainxch.auth.presentation.model.AuthLoginState import zed.rainxch.auth.presentation.model.GithubDeviceStartUi import zed.rainxch.core.domain.logging.GitHubStoreLogger import zed.rainxch.core.domain.model.GithubDeviceStart import zed.rainxch.core.domain.utils.BrowserHelper import zed.rainxch.core.domain.utils.ClipboardHelper import zed.rainxch.githubstore.core.presentation.res.* class AuthenticationViewModel( private val authenticationRepository: AuthenticationRepository, private val browserHelper: BrowserHelper, private val clipboardHelper: ClipboardHelper, private val scope: CoroutineScope, private val logger: GitHubStoreLogger, ) : ViewModel() { private var hasLoadedInitialData = false private var countdownJob: Job? = null private val _state: MutableStateFlow = MutableStateFlow(AuthenticationState()) private val _events = Channel(capacity = Channel.BUFFERED) val events = _events.receiveAsFlow() val state = _state .onStart { if (!hasLoadedInitialData) { scope.launch { authenticationRepository.accessTokenFlow.collect { token -> _state.update { it.copy( loginState = if (token.isNullOrEmpty()) { AuthLoginState.LoggedOut } else { _events.trySend(AuthenticationEvents.OnNavigateToMain) AuthLoginState.LoggedIn }, ) } } } hasLoadedInitialData = true } }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000L), initialValue = AuthenticationState(), ) fun onAction(action: AuthenticationAction) { when (action) { is AuthenticationAction.StartLogin -> { startLogin() } is AuthenticationAction.CopyCode -> { copyCode(action.start) } is AuthenticationAction.OpenGitHub -> { openGitHub(action.start) } AuthenticationAction.MarkLoggedIn -> { _state.update { it.copy(loginState = AuthLoginState.LoggedIn) } } AuthenticationAction.MarkLoggedOut -> { _state.update { it.copy(loginState = AuthLoginState.LoggedOut) } } is AuthenticationAction.OnInfo -> { _state.update { it.copy( info = action.message, ) } } AuthenticationAction.SkipLogin -> { _events.trySend(AuthenticationEvents.OnNavigateToMain) } } } private fun startCountdown(start: GithubDeviceStart) { countdownJob?.cancel() countdownJob = viewModelScope.launch { var remaining = start.expiresInSec while (remaining > 0) { _state.update { currentState -> val loginState = currentState.loginState if (loginState is AuthLoginState.DevicePrompt) { currentState.copy( loginState = loginState.copy(remainingSeconds = remaining), ) } else { return@launch } } delay(1000L) remaining-- } _state.update { it.copy( loginState = AuthLoginState.Error( message = getString(Res.string.auth_error_code_expired), recoveryHint = getString(Res.string.auth_hint_try_again), ), ) } } } private fun startLogin() { viewModelScope.launch { try { val start = withContext(Dispatchers.IO) { authenticationRepository.startDeviceFlow() } withContext(Dispatchers.Main.immediate) { _state.update { it.copy( loginState = AuthLoginState.DevicePrompt( start = start.toUi(), remainingSeconds = start.expiresInSec, ), copied = false, ) } startCountdown(start) try { clipboardHelper.copy( label = getString(Res.string.enter_code_on_github), text = start.userCode, ) _state.update { it.copy(copied = true) } } catch (e: Exception) { logger.debug("Failed to copy to clipboard: ${e.message}") } } withContext(Dispatchers.IO) { authenticationRepository.awaitDeviceToken(start = start) } countdownJob?.cancel() withContext(Dispatchers.Main.immediate) { _state.update { it.copy(loginState = AuthLoginState.LoggedIn) } _events.trySend(AuthenticationEvents.OnNavigateToMain) } } catch (e: CancellationException) { throw e } catch (t: Throwable) { countdownJob?.cancel() val (message, hint) = categorizeError(t) withContext(Dispatchers.Main.immediate) { _state.update { it.copy( loginState = AuthLoginState.Error( message = message, recoveryHint = hint, ), ) } } } } } private suspend fun categorizeError(t: Throwable): Pair { val msg = t.message ?: return getString(Res.string.error_unknown) to null val lowerMsg = msg.lowercase() return when { "timeout" in lowerMsg || "timed out" in lowerMsg -> { msg to getString(Res.string.auth_hint_check_connection) } "network" in lowerMsg || "unresolvedaddress" in lowerMsg || "connect" in lowerMsg -> { msg to getString(Res.string.auth_hint_check_connection) } "expired" in lowerMsg || "expire" in lowerMsg -> { msg to getString(Res.string.auth_hint_try_again) } "denied" in lowerMsg || "access_denied" in lowerMsg -> { msg to getString(Res.string.auth_hint_denied) } else -> { msg to null } } } override fun onCleared() { super.onCleared() countdownJob?.cancel() } private fun openGitHub(start: GithubDeviceStartUi) { viewModelScope.launch(Dispatchers.Main.immediate) { try { val url = start.verificationUriComplete ?: start.verificationUri browserHelper.openUrl(url) } catch (e: Exception) { logger.debug("⚠️ Failed to open browser: ${e.message}") } } } private fun copyCode(start: GithubDeviceStartUi) { viewModelScope.launch(Dispatchers.Main.immediate) { try { clipboardHelper.copy( label = "GitHub Code", text = start.userCode, ) _state.update { val currentRemaining = (it.loginState as? AuthLoginState.DevicePrompt)?.remainingSeconds ?: 0 it.copy( loginState = AuthLoginState.DevicePrompt(start, currentRemaining), copied = true, ) } } catch (e: Exception) { logger.debug("⚠️ Failed to copy to clipboard: ${e.message}") _state.update { val currentRemaining = (it.loginState as? AuthLoginState.DevicePrompt)?.remainingSeconds ?: 0 it.copy( loginState = AuthLoginState.DevicePrompt(start, currentRemaining), copied = false, ) } } } } } ================================================ FILE: feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/mapper/GithubDeviceStartMapper.kt ================================================ package zed.rainxch.auth.presentation.mapper import zed.rainxch.auth.presentation.model.GithubDeviceStartUi import zed.rainxch.core.domain.model.GithubDeviceStart fun GithubDeviceStart.toUi(): GithubDeviceStartUi = GithubDeviceStartUi( deviceCode = deviceCode, userCode = userCode, verificationUri = verificationUri, verificationUriComplete = verificationUriComplete, intervalSec = intervalSec, expiresInSec = expiresInSec, ) ================================================ FILE: feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/model/AuthLoginState.kt ================================================ package zed.rainxch.auth.presentation.model sealed class AuthLoginState { data object LoggedOut : AuthLoginState() data class DevicePrompt( val start: GithubDeviceStartUi, val remainingSeconds: Int = 0, ) : AuthLoginState() data object Pending : AuthLoginState() data object LoggedIn : AuthLoginState() data class Error( val message: String, val recoveryHint: String? = null, ) : AuthLoginState() } ================================================ FILE: feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/model/GithubDeviceStartUi.kt ================================================ package zed.rainxch.auth.presentation.model data class GithubDeviceStartUi( val deviceCode: String, val userCode: String, val verificationUri: String, val verificationUriComplete: String? = null, val intervalSec: Int = 5, val expiresInSec: Int, ) ================================================ FILE: feature/details/CLAUDE.md ================================================ # CLAUDE.md - Details Feature ## Purpose Repository detail screen. Displays full info for a GitHub repository including owner profile, stats, releases with download links, readme rendering (with translation support), and installation/update flow. This is the most complex feature module. ## Module Structure ``` feature/details/ ├── domain/ │ ├── model/ │ │ ├── ReleaseCategory.kt # Release filtering categories │ │ ├── RepoStats.kt # Stars, forks, open issues │ │ ├── SupportedLanguage.kt # Languages for readme translation │ │ └── TranslationResult.kt # Translation response model │ └── repository/ │ ├── DetailsRepository.kt # Repo, releases, readme, stats, user profile │ └── TranslationRepository.kt # Readme translation ├── data/ │ ├── di/SharedModule.kt # Koin: detailsModule │ ├── repository/ │ │ ├── DetailsRepositoryImpl.kt # API calls + readme localization │ │ └── TranslationRepositoryImpl.kt # Translation API integration │ ├── model/ReadmeAttempt.kt # Readme fetch attempt tracking │ └── utils/ │ ├── ReadmeLocalizationHelper.kt # Find readme in user's language │ └── preprocessMarkdown.kt # Markdown preprocessing └── presentation/ ├── DetailsViewModel.kt # State management for detail screen ├── DetailsState.kt # Repo, releases, readme, download progress, etc. ├── DetailsAction.kt # Load, download, install, favourite, star, etc. ├── DetailsEvent.kt # Navigation, toast events ├── DetailsRoot.kt # Main composable ├── model/ │ ├── DownloadStage.kt # Download progress tracking │ ├── InstallLogItem.kt # Installation log entries │ ├── LogResult.kt # Log result types │ ├── ShowDowngradeWarning.kt # Downgrade confirmation model │ ├── SupportedLanguages.kt # UI language list │ ├── TranslationState.kt # Translation UI state │ └── TranslationTarget.kt # Translation target selection ├── components/ │ ├── AppHeader.kt # App icon, name, developer │ ├── LanguagePicker.kt # Readme translation language selector │ ├── ReleaseAssetsPicker.kt # Asset selection for download │ ├── SmartInstallButton.kt # Context-aware install/update/open button │ ├── StatItem.kt # Individual stat display │ ├── TranslationControls.kt # Translation UI controls │ ├── VersionPicker.kt # Release version selector │ ├── VersionTypePicker.kt # Stable/pre-release filter │ └── sections/ │ ├── About.kt # Description & topics │ ├── Header.kt # Top header section │ ├── Logs.kt # Installation/download logs │ ├── Owner.kt # Repository owner info │ ├── ReportIssue.kt # Issue reporting section │ ├── Stats.kt # Stars, forks, issues │ └── WhatsNew.kt # Release changelog ├── states/ErrorState.kt # Error display composable └── utils/ ├── LocalTopbarLiquidState.kt ├── LogResultAsText.kt # Log result formatting ├── MarkdownImageTransformer.kt # Transform relative image URLs ├── MarkdownUtils.kt # Markdown preprocessing └── SystemArchitecture.kt # Platform architecture detection ``` ## Key Interfaces ```kotlin interface DetailsRepository { suspend fun getRepositoryById(id: Long): GithubRepoSummary suspend fun getRepositoryByOwnerAndName(owner: String, name: String): GithubRepoSummary suspend fun getLatestPublishedRelease(owner: String, repo: String, defaultBranch: String): GithubRelease? suspend fun getAllReleases(owner: String, repo: String, defaultBranch: String): List suspend fun getReadme(owner: String, repo: String, defaultBranch: String): Triple? suspend fun getRepoStats(owner: String, repo: String): RepoStats suspend fun getUserProfile(username: String): GithubUserProfile } interface TranslationRepository { suspend fun translate(text: String, targetLanguage: SupportedLanguage): TranslationResult } ``` ## Navigation Route: `GithubStoreGraph.DetailsScreen(repositoryId: Long, owner: String, repo: String, isComingFromUpdate: Boolean)` Can be reached via repo ID or owner+name (for deep links). Falls back to owner+name lookup if `repositoryId == -1`. `isComingFromUpdate` flag indicates navigation from an update notification. ## Implementation Notes - Readme supports localization: `ReadmeLocalizationHelper` tries to find readme in user's language first - Readme translation: `TranslationRepository` translates readme content to user's chosen language via `LanguagePicker` - Markdown rendering uses `multiplatform-markdown-renderer` with custom `MarkdownImageTransformer` for relative URLs - Download flow tracks stages via `DownloadStage` (idle → downloading → installing → done) - `SmartInstallButton` changes behavior based on installed/update-available/not-installed state - `ReleaseAssetsPicker` allows selecting specific assets; `VersionTypePicker` filters stable vs pre-release - Version picker allows selecting specific releases for download - Downgrade warning shown when installing an older version than currently installed - Integrates with `FavouritesRepository`, `StarredRepository`, `InstalledAppsRepository` from core - Uses `Downloader` and `Installer` interfaces from core/domain for platform-specific download/install - On Android, install may use Shizuku (silent) or standard system installer depending on user preference in profile settings ================================================ FILE: feature/details/data/.gitignore ================================================ /build ================================================ FILE: feature/details/data/build.gradle.kts ================================================ plugins { alias(libs.plugins.convention.kmp.library) alias(libs.plugins.convention.buildkonfig) } kotlin { sourceSets { commonMain { dependencies { implementation(libs.kotlin.stdlib) implementation(projects.core.domain) implementation(projects.core.data) implementation(projects.feature.details.domain) implementation(libs.kotlinx.coroutines.core) implementation(libs.bundles.ktor.common) implementation(libs.bundles.koin.common) } } androidMain { dependencies { } } jvmMain { dependencies { } } } } ================================================ FILE: feature/details/data/src/androidMain/AndroidManifest.xml ================================================ ================================================ FILE: feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/di/SharedModule.kt ================================================ package zed.rainxch.details.data.di import org.koin.dsl.module import zed.rainxch.details.data.repository.DetailsRepositoryImpl import zed.rainxch.details.data.repository.TranslationRepositoryImpl import zed.rainxch.details.domain.repository.DetailsRepository import zed.rainxch.details.domain.repository.TranslationRepository val detailsModule = module { single { DetailsRepositoryImpl( logger = get(), httpClient = get(), localizationManager = get(), cacheManager = get(), ) } single { TranslationRepositoryImpl( localizationManager = get(), ) } } ================================================ FILE: feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/dto/AttestationsResponse.kt ================================================ package zed.rainxch.details.data.dto import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonObject @Serializable data class AttestationsResponse( val attestations: List = emptyList(), ) ================================================ FILE: feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/model/ReadmeAttempt.kt ================================================ package zed.rainxch.details.data.model data class ReadmeAttempt( val path: String, val filename: String, val priority: Int, ) ================================================ FILE: feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/DetailsRepositoryImpl.kt ================================================ package zed.rainxch.details.data.repository import io.ktor.client.HttpClient import io.ktor.client.request.get import io.ktor.client.request.header import io.ktor.client.request.parameter import io.ktor.http.HttpHeaders import kotlinx.serialization.Serializable import zed.rainxch.core.data.cache.CacheManager import zed.rainxch.core.data.cache.CacheManager.CacheTtl.README import zed.rainxch.core.data.cache.CacheManager.CacheTtl.RELEASES import zed.rainxch.core.data.cache.CacheManager.CacheTtl.REPO_DETAILS import zed.rainxch.core.data.cache.CacheManager.CacheTtl.REPO_STATS import zed.rainxch.core.data.cache.CacheManager.CacheTtl.USER_PROFILE import zed.rainxch.core.data.dto.ReleaseNetwork import zed.rainxch.core.data.dto.RepoByIdNetwork import zed.rainxch.core.data.dto.RepoInfoNetwork import zed.rainxch.core.data.dto.UserProfileNetwork import zed.rainxch.details.data.dto.AttestationsResponse import zed.rainxch.core.data.mappers.toDomain import zed.rainxch.core.data.network.executeRequest import zed.rainxch.core.data.services.LocalizationManager import zed.rainxch.core.domain.logging.GitHubStoreLogger import zed.rainxch.core.domain.model.GithubRelease import zed.rainxch.core.domain.model.GithubRepoSummary import zed.rainxch.core.domain.model.GithubUser import zed.rainxch.core.domain.model.GithubUserProfile import zed.rainxch.details.data.utils.ReadmeLocalizationHelper import zed.rainxch.details.data.utils.preprocessMarkdown import zed.rainxch.details.domain.model.RepoStats import zed.rainxch.details.domain.repository.DetailsRepository class DetailsRepositoryImpl( private val httpClient: HttpClient, private val localizationManager: LocalizationManager, private val logger: GitHubStoreLogger, private val cacheManager: CacheManager, ) : DetailsRepository { @Serializable private data class CachedReadme( val content: String, val languageCode: String?, val path: String, ) private val readmeHelper = ReadmeLocalizationHelper(localizationManager) private fun RepoByIdNetwork.toGithubRepoSummary(): GithubRepoSummary = GithubRepoSummary( id = id, name = name, fullName = fullName, owner = GithubUser( id = owner.id, login = owner.login, avatarUrl = owner.avatarUrl, htmlUrl = owner.htmlUrl, ), description = description, htmlUrl = htmlUrl, stargazersCount = stars, forksCount = forks, language = language, topics = topics, releasesUrl = "https://api.github.com/repos/${owner.login}/$name/releases{/id}", updatedAt = updatedAt, defaultBranch = defaultBranch, ) override suspend fun getRepositoryById(id: Long): GithubRepoSummary { val cacheKey = "details:repo_id:$id" cacheManager.get(cacheKey)?.let { cached -> logger.debug("Cache hit for repo id=$id") return cached } return try { val result = httpClient .executeRequest { get("/repositories/$id") { header(HttpHeaders.Accept, "application/vnd.github+json") } }.getOrThrow() .toGithubRepoSummary() cacheManager.put(cacheKey, result, REPO_DETAILS) result } catch (e: Exception) { cacheManager.getStale(cacheKey)?.let { stale -> logger.debug("Network error, using stale cache for repo id=$id") return stale } throw e } } override suspend fun getRepositoryByOwnerAndName( owner: String, name: String, ): GithubRepoSummary { val cacheKey = "details:repo:$owner/$name" cacheManager.get(cacheKey)?.let { cached -> logger.debug("Cache hit for repo $owner/$name") return cached } return try { val result = httpClient .executeRequest { get("/repos/$owner/$name") { header(HttpHeaders.Accept, "application/vnd.github+json") } }.getOrThrow() .toGithubRepoSummary() cacheManager.put(cacheKey, result, REPO_DETAILS) result } catch (e: Exception) { cacheManager.getStale(cacheKey)?.let { stale -> logger.debug("Network error, using stale cache for $owner/$name") return stale } throw e } } override suspend fun getLatestPublishedRelease( owner: String, repo: String, defaultBranch: String, ): GithubRelease? { val cacheKey = "details:latest_release:$owner/$repo" cacheManager.get(cacheKey)?.let { cached -> logger.debug("Cache hit for latest release $owner/$repo") return cached } return try { val releases = httpClient .executeRequest> { get("/repos/$owner/$repo/releases") { header(HttpHeaders.Accept, "application/vnd.github+json") parameter("per_page", 10) } }.getOrNull() ?: return null val latest = releases .asSequence() .filter { (it.draft != true) && (it.prerelease != true) } .maxByOrNull { it.publishedAt ?: it.createdAt ?: "" } ?: return null val result = latest .copy( body = processReleaseBody(latest.body, owner, repo, defaultBranch), ).toDomain() cacheManager.put(cacheKey, result, RELEASES) result } catch (e: Exception) { cacheManager.getStale(cacheKey)?.let { stale -> logger.debug("Network error, using stale cache for latest release $owner/$repo") return stale } throw e } } override suspend fun getAllReleases( owner: String, repo: String, defaultBranch: String, ): List { val cacheKey = "details:releases:$owner/$repo" cacheManager.get>(cacheKey)?.let { cached -> if (cached.isNotEmpty()) { logger.debug("Cache hit for all releases $owner/$repo: ${cached.size} releases") return cached } } return try { val releases = httpClient .executeRequest> { get("/repos/$owner/$repo/releases") { header(HttpHeaders.Accept, "application/vnd.github+json") parameter("per_page", 30) } }.getOrNull() ?: return emptyList() val result = releases .filter { it.draft != true } .map { release -> release .copy( body = processReleaseBody(release.body, owner, repo, defaultBranch), ).toDomain() }.sortedByDescending { it.publishedAt } if (result.isNotEmpty()) { cacheManager.put(cacheKey, result, RELEASES) } result } catch (e: Exception) { cacheManager.getStale>(cacheKey)?.let { stale -> logger.debug("Network error, using stale cache for releases $owner/$repo") return stale } throw e } } private fun processReleaseBody( body: String?, owner: String, repo: String, defaultBranch: String, ): String? = body ?.replace("
", "") ?.replace("
", "") ?.replace("", "") ?.replace("", "") ?.replace("\r\n", "\n") ?.let { rawMarkdown -> preprocessMarkdown( markdown = rawMarkdown, baseUrl = "https://raw.githubusercontent.com/$owner/$repo/$defaultBranch/", ) } override suspend fun getReadme( owner: String, repo: String, defaultBranch: String, ): Triple? { val cacheKey = "details:readme:$owner/$repo" cacheManager.get(cacheKey)?.let { cached -> logger.debug("Cache hit for readme $owner/$repo") return Triple(cached.content, cached.languageCode, cached.path) } val result = fetchReadmeFromApi(owner, repo, defaultBranch) if (result != null) { val cachedReadme = CachedReadme( content = result.first, languageCode = result.second, path = result.third, ) cacheManager.put(cacheKey, cachedReadme, README) } return result } private suspend fun fetchReadmeFromApi( owner: String, repo: String, defaultBranch: String, ): Triple? { val baseUrl = "https://raw.githubusercontent.com/$owner/$repo/$defaultBranch/" val path = "README.md" return try { val rawMarkdown = httpClient .executeRequest { get("$baseUrl$path") }.getOrNull() if (rawMarkdown != null) { val processed = preprocessMarkdown(markdown = rawMarkdown, baseUrl = baseUrl) val detectedLang = readmeHelper.detectReadmeLanguage(processed) logger.debug("Fetched README.md (detected language: ${detectedLang ?: "unknown"})") Triple(processed, detectedLang, path) } else { logger.error("Failed to fetch README.md for $owner/$repo") null } } catch (e: Throwable) { logger.error("Failed to fetch README.md: ${e.message}") null } } override suspend fun getRepoStats( owner: String, repo: String, ): RepoStats { val cacheKey = "details:stats:$owner/$repo" cacheManager.get(cacheKey)?.let { cached -> logger.debug("Cache hit for repo stats $owner/$repo") return cached } return try { val info = httpClient .executeRequest { get("/repos/$owner/$repo") { header(HttpHeaders.Accept, "application/vnd.github+json") } }.getOrThrow() val result = RepoStats( stars = info.stars, forks = info.forks, openIssues = info.openIssues, ) cacheManager.put(cacheKey, result, REPO_STATS) result } catch (e: Exception) { cacheManager.getStale(cacheKey)?.let { stale -> logger.debug("Network error, using stale cache for stats $owner/$repo") return stale } throw e } } override suspend fun getUserProfile(username: String): GithubUserProfile { val cacheKey = "details:profile:$username" cacheManager.get(cacheKey)?.let { cached -> logger.debug("Cache hit for user profile $username") return cached } return try { val user = httpClient .executeRequest { get("/users/$username") { header(HttpHeaders.Accept, "application/vnd.github+json") } }.getOrThrow() val result = GithubUserProfile( id = user.id, login = user.login, name = user.name, bio = user.bio, avatarUrl = user.avatarUrl, htmlUrl = user.htmlUrl, followers = user.followers, following = user.following, publicRepos = user.publicRepos, location = user.location, company = user.company, blog = user.blog, twitterUsername = user.twitterUsername, ) cacheManager.put(cacheKey, result, USER_PROFILE) result } catch (e: Exception) { cacheManager.getStale(cacheKey)?.let { stale -> logger.debug("Network error, using stale cache for profile $username") return stale } throw e } } override suspend fun checkAttestations( owner: String, repo: String, sha256Digest: String, ): Boolean = try { val response = httpClient .executeRequest { get("/repos/$owner/$repo/attestations/sha256:$sha256Digest") { header(HttpHeaders.Accept, "application/vnd.github+json") } }.getOrNull() response != null && response.attestations.isNotEmpty() } catch (e: Exception) { logger.debug("Attestation check failed for $owner/$repo: ${e.message}") false } } ================================================ FILE: feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/TranslationRepositoryImpl.kt ================================================ package zed.rainxch.details.data.repository import io.ktor.client.HttpClient import io.ktor.client.request.get import io.ktor.client.request.parameter import io.ktor.client.statement.bodyAsText import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonPrimitive import zed.rainxch.core.data.network.createPlatformHttpClient import zed.rainxch.core.data.services.LocalizationManager import zed.rainxch.core.domain.model.ProxyConfig import zed.rainxch.details.domain.model.TranslationResult import zed.rainxch.details.domain.repository.TranslationRepository import kotlin.time.Clock import kotlin.time.ExperimentalTime class TranslationRepositoryImpl( private val localizationManager: LocalizationManager, ) : TranslationRepository { private val httpClient: HttpClient = createPlatformHttpClient(ProxyConfig.None) private val json = Json { ignoreUnknownKeys = true isLenient = true } private val cacheMutex = Mutex() private val cache = LinkedHashMap(MAX_CACHE_SIZE, 0.75f, true) private val maxChunkSize = 4500 @OptIn(ExperimentalTime::class) override suspend fun translate( text: String, targetLanguage: String, sourceLanguage: String, ): TranslationResult { val cacheKey = CacheKey(text, targetLanguage, sourceLanguage) cacheMutex.withLock { cache[cacheKey]?.let { cached -> if (!cached.isExpired()) return cached.result cache.remove(cacheKey) } } val chunks = chunkText(text) val translatedParts = mutableListOf>() var detectedLang: String? = null for ((chunkText, delimiter) in chunks) { val response = translateSingleChunk(chunkText, targetLanguage, sourceLanguage) translatedParts.add(response.translatedText to delimiter) if (detectedLang == null) { detectedLang = response.detectedSourceLanguage } } val result = TranslationResult( translatedText = translatedParts .dropLast(1) .joinToString("") { (text, delim) -> text + delim } + translatedParts.lastOrNull()?.first.orEmpty(), detectedSourceLanguage = detectedLang, ) cacheMutex.withLock { if (cache.size >= MAX_CACHE_SIZE) { val firstKey = cache.keys.first() cache.remove(firstKey) } cache[cacheKey] = CachedTranslation(result) } return result } override fun getDeviceLanguageCode(): String = localizationManager.getPrimaryLanguageCode() private suspend fun translateSingleChunk( text: String, targetLanguage: String, sourceLanguage: String, ): TranslationResult { val responseText = httpClient .get( "https://translate.googleapis.com/translate_a/single", ) { parameter("client", "gtx") parameter("sl", sourceLanguage) parameter("tl", targetLanguage) parameter("dt", "t") parameter("q", text) }.bodyAsText() return parseTranslationResponse(responseText) } private fun parseTranslationResponse(responseText: String): TranslationResult { val root = json.parseToJsonElement(responseText).jsonArray val segments = root[0].jsonArray val translatedText = segments.joinToString("") { segment -> segment.jsonArray[0].jsonPrimitive.content } val detectedLang = try { root[2].jsonPrimitive.content } catch (_: Exception) { null } return TranslationResult( translatedText = translatedText, detectedSourceLanguage = detectedLang, ) } private fun chunkText(text: String): List> { val paragraphs = text.split("\n\n") val chunks = mutableListOf>() val currentChunk = StringBuilder() for (paragraph in paragraphs) { if (paragraph.length > maxChunkSize) { if (currentChunk.isNotEmpty()) { chunks.add(Pair(currentChunk.toString(), "\n\n")) currentChunk.clear() } chunkLargeParagraph(paragraph, chunks) } else if (currentChunk.length + paragraph.length + 2 > maxChunkSize) { chunks.add(Pair(currentChunk.toString(), "\n\n")) currentChunk.clear() currentChunk.append(paragraph) } else { if (currentChunk.isNotEmpty()) currentChunk.append("\n\n") currentChunk.append(paragraph) } } if (currentChunk.isNotEmpty()) { chunks.add(Pair(currentChunk.toString(), "\n\n")) } return chunks } private fun chunkLargeParagraph( paragraph: String, chunks: MutableList>, ) { val lines = paragraph.split("\n") val currentChunk = StringBuilder() for (line in lines) { if (line.length > maxChunkSize) { if (currentChunk.isNotEmpty()) { chunks.add(Pair(currentChunk.toString(), "\n")) currentChunk.clear() } var start = 0 while (start < line.length) { val end = minOf(start + maxChunkSize, line.length) chunks.add(Pair(line.substring(start, end), "")) start = end } } else if (currentChunk.length + line.length + 1 > maxChunkSize) { chunks.add(Pair(currentChunk.toString(), "\n")) currentChunk.clear() currentChunk.append(line) } else { if (currentChunk.isNotEmpty()) currentChunk.append("\n") currentChunk.append(line) } } if (currentChunk.isNotEmpty()) { chunks.add(Pair(currentChunk.toString(), "\n")) } } companion object { private const val MAX_CACHE_SIZE = 50 private const val CACHE_TTL_MS = 30 * 60 * 1000L // 30 minutes } @OptIn(ExperimentalTime::class) private class CachedTranslation( val result: TranslationResult, private val timestamp: Long = Clock.System.now().toEpochMilliseconds(), ) { fun isExpired(): Boolean = Clock.System.now().toEpochMilliseconds() - timestamp > CACHE_TTL_MS } private data class CacheKey( val text: String, val targetLanguage: String, val sourceLanguage: String, ) } ================================================ FILE: feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/utils/ReadmeLocalizationHelper.kt ================================================ package zed.rainxch.details.data.utils import zed.rainxch.details.data.model.ReadmeAttempt class ReadmeLocalizationHelper( private val localizationManager: zed.rainxch.core.data.services.LocalizationManager, ) { private val searchPaths = listOf( ".github", "", "docs", "doc", ) fun generateReadmeAttempts(): List { val attempts = mutableListOf() val currentLang = localizationManager.getCurrentLanguageCode().lowercase() val primaryLang = localizationManager.getPrimaryLanguageCode().lowercase() var globalPriority = 0 for ((pathIndex, searchPath) in searchPaths.withIndex()) { val pathPrefix = if (searchPath.isEmpty()) "" else "$searchPath/" var localPriority = 0 if (currentLang.contains("-")) { attempts.add( ReadmeAttempt( path = "${pathPrefix}README.$currentLang.md", filename = "README.$currentLang.md", priority = globalPriority + localPriority++, ), ) attempts.add( ReadmeAttempt( path = "${pathPrefix}README.${currentLang.replace("-", "_")}.md", filename = "README.${currentLang.replace("-", "_")}.md", priority = globalPriority + localPriority++, ), ) } attempts.add( ReadmeAttempt( path = "${pathPrefix}README.$primaryLang.md", filename = "README.$primaryLang.md", priority = globalPriority + localPriority++, ), ) if (currentLang.contains("-")) { val parts = currentLang.split("-") attempts.add( ReadmeAttempt( path = "${pathPrefix}README.${parts[0].uppercase()}.md", filename = "README.${parts[0].uppercase()}.md", priority = globalPriority + localPriority++, ), ) attempts.add( ReadmeAttempt( path = "${pathPrefix}README-${parts[0].uppercase()}.md", filename = "README-${parts[0].uppercase()}.md", priority = globalPriority + localPriority++, ), ) } else { attempts.add( ReadmeAttempt( path = "${pathPrefix}README.${primaryLang.uppercase()}.md", filename = "README.${primaryLang.uppercase()}.md", priority = globalPriority + localPriority++, ), ) attempts.add( ReadmeAttempt( path = "${pathPrefix}README-${primaryLang.uppercase()}.md", filename = "README-${primaryLang.uppercase()}.md", priority = globalPriority + localPriority++, ), ) } attempts.add( ReadmeAttempt( path = "${pathPrefix}README_$primaryLang.md", filename = "README_$primaryLang.md", priority = globalPriority + localPriority++, ), ) attempts.add( ReadmeAttempt( path = "${pathPrefix}readme.$primaryLang.md", filename = "readme.$primaryLang.md", priority = globalPriority + localPriority++, ), ) attempts.add( ReadmeAttempt( path = "${pathPrefix}README.md", filename = "README.md", priority = globalPriority + localPriority++, ), ) if (primaryLang != "en") { attempts.add( ReadmeAttempt( path = "${pathPrefix}README.en.md", filename = "README.en.md", priority = globalPriority + localPriority++, ), ) attempts.add( ReadmeAttempt( path = "${pathPrefix}README.EN.md", filename = "README.EN.md", priority = globalPriority + localPriority++, ), ) attempts.add( ReadmeAttempt( path = "${pathPrefix}README-EN.md", filename = "README-EN.md", priority = globalPriority + localPriority++, ), ) } globalPriority += 100 * (pathIndex + 1) } return attempts.sortedBy { it.priority } } fun detectReadmeLanguage(content: String): String? { val sample = content.take(1000) val sampleLower = sample.lowercase() val chineseChars = sample.count { it in '\u4e00'..'\u9fff' } val japaneseHiragana = sample.count { it in '\u3040'..'\u309f' } val japaneseKatakana = sample.count { it in '\u30a0'..'\u30ff' } val koreanChars = sample.count { it in '\uac00'..'\ud7af' } val arabicChars = sample.count { it in '\u0600'..'\u06ff' } val cyrillicChars = sample.count { it in 'а'..'я' || it in 'А'..'Я' || it == 'ё' || it == 'Ё' } val totalChars = sample.length val threshold = 0.15 return when { chineseChars > totalChars * threshold -> { "zh" } (japaneseHiragana + japaneseKatakana) > totalChars * threshold -> { "ja" } koreanChars > totalChars * threshold -> { "ko" } arabicChars > totalChars * threshold -> { "ar" } cyrillicChars > totalChars * threshold -> { "ru" } else -> { val englishIndicators = listOf( "\\bthe\\b", "\\band\\b", "\\bfor\\b", "\\bwith\\b", "\\bthis\\b", "\\bthat\\b", "\\bfrom\\b", "\\bare\\b", "\\bwas\\b", "\\bhave\\b", "\\bhas\\b", "\\bwill\\b", "\\byou\\b", "\\bcan\\b", "\\buse\\b", "\\binstall\\b", ) val matchCount = englishIndicators.count { pattern -> Regex(pattern, RegexOption.IGNORE_CASE).containsMatchIn(sampleLower) } if (matchCount >= 4) { "en" } else { null } } } } } ================================================ FILE: feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/utils/preprocessMarkdown.kt ================================================ package zed.rainxch.details.data.utils fun preprocessMarkdown( markdown: String, baseUrl: String, ): String { val normalizedBaseUrl = if (baseUrl.endsWith("/")) baseUrl else "$baseUrl/" var processed = markdown fun normalizeGitHubUrl(url: String): String = if (url.contains("github.com") && url.contains("/blob/")) { url .replace("github.com", "raw.githubusercontent.com") .replace("/blob/", "/") } else { url } fun isSvgUrl(url: String): Boolean { val lower = url.lowercase() return lower.endsWith(".svg") || lower.contains(".svg?") || lower.contains(".svg#") || lower.contains("/svg-badge") || lower.contains("badge.svg") } fun isBadgeUrl(url: String): Boolean { val lower = url.lowercase() return lower.contains("img.shields.io") || lower.contains("shields.io/badge") || lower.contains("badge.fury.io") || lower.contains("badgen.net") || lower.contains("repology.org/badge") || lower.contains("hosted.weblate.org/widget") || lower.contains("codecov.io") || lower.contains("coveralls.io") || lower.contains("travis-ci.") || lower.contains("circleci.com") || lower.contains("github.com/workflows") || (lower.contains("/badge") && isSvgUrl(lower)) } fun shouldSkipImage(url: String): Boolean = isSvgUrl(url) || isBadgeUrl(url) fun resolveUrl(path: String): String { val trimmed = path.trim() val isAbsolute = trimmed.startsWith("http://") || trimmed.startsWith("https://") || trimmed.startsWith("data:") return if (isAbsolute) { normalizeGitHubUrl(trimmed) } else { when { trimmed.startsWith("./") -> { "$normalizedBaseUrl${trimmed.removePrefix("./")}" } trimmed.startsWith("/") -> { "$normalizedBaseUrl${trimmed.removePrefix("/")}" } trimmed.startsWith("../") -> { var base = normalizedBaseUrl.trimEnd('/') var rel = trimmed while (rel.startsWith("../")) { base = base.substringBeforeLast('/', base) rel = rel.removePrefix("../") } "$base/$rel" } else -> { "$normalizedBaseUrl$trimmed" } } } } // ======================================================================== // Phase 0: Handle reference-style markdown definitions and usages // ======================================================================== // Reference definitions: [ref-name]: https://example.com/image.svg // Reference usages: ![alt][ref-name] or [![img-ref]][link-ref] // 0a. Parse all reference definitions val refDefinitionRegex = Regex( """^\[([^\]]+)\]:\s*(\S+).*$""", RegexOption.MULTILINE, ) val referenceMap = mutableMapOf() for (match in refDefinitionRegex.findAll(processed)) { val refName = match.groupValues[1].lowercase() val url = match.groupValues[2] referenceMap[refName] = url } // 0b. Identify which references point to SVGs/badges val skipRefNames = referenceMap .filter { (_, url) -> shouldSkipImage(resolveUrl(url)) }.keys // 0c. Remove reference-style image usages that point to SVGs: ![alt][svg-ref] if (skipRefNames.isNotEmpty()) { processed = processed.replace( Regex("""!\[([^\]]*)\]\[([^\]]+)\]"""), ) { match -> val alt = match.groupValues[1] val refName = match.groupValues[2].lowercase() if (refName in skipRefNames) { if (alt.isNotEmpty()) "**$alt**" else "" } else { match.value } } } // 0d. Resolve remaining reference-style images to inline format: ![alt][ref] → ![alt](url) processed = processed.replace( Regex("""!\[([^\]]*)\]\[([^\]]+)\]"""), ) { match -> val alt = match.groupValues[1] val refName = match.groupValues[2].lowercase() val url = referenceMap[refName] if (url != null) { val resolved = resolveUrl(url) "![$alt]($resolved)" } else { match.value } } // 0e. Handle nested badge-as-link patterns: [![badge-ref]][link-ref] // After 0c strips the inner image, this can leave [**text**][link-ref] or [][link-ref] processed = processed.replace( Regex("""\[(\*\*[^*]*\*\*)\]\[([^\]]+)\]"""), ) { match -> val boldText = match.groupValues[1] val refName = match.groupValues[2].lowercase() val url = referenceMap[refName] if (url != null) { "[$boldText](${resolveUrl(url)})" } else { boldText } } // Clean empty bracket patterns left from stripped badge images: [][ref] processed = processed.replace( Regex("""\[\s*\]\[([^\]]+)\]"""), "", ) // 0f. Handle reference-style links: [text][ref] → [text](url) processed = processed.replace( Regex("""\[([^\]]+)\]\[([^\]]+)\]"""), ) { match -> val text = match.groupValues[1] val refName = match.groupValues[2].lowercase() val url = referenceMap[refName] // Don't convert if text looks like it was already an image (starts with !) if (url != null && !text.startsWith("!")) { "[$text](${resolveUrl(url)})" } else { match.value } } // 0g. Remove all reference definitions that were resolved processed = processed.replace( Regex("""^\[([^\]]+)\]:\s*\S+.*$""", RegexOption.MULTILINE), ) { match -> val refName = match.groupValues[1].lowercase() if (refName in referenceMap) "" else match.value } // ======================================================================== // Phase 1: HTML → Markdown conversions // ======================================================================== // 1. Unwrap elements → keep only the fallback processed = processed.replace( Regex( """]*>.*?(]*?>).*?""", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL), ), ) { match -> match.groupValues[1] } // Also strip orphaned tags (outside ) processed = processed.replace( Regex("""]*?/?>""", RegexOption.IGNORE_CASE), "", ) // 2. Unwrap tags that wrap tags — keep the for step 3 processed = processed.replace( Regex( """]*?>\s*(]*?>)\s*""", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL), ), ) { match -> match.groupValues[1] } // 3. Convert tags → markdown images (handles multiline img tags) processed = processed.replace( Regex( """]*?)\s*/?>""", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL), ), ) { imgMatch -> val imgTag = imgMatch.groupValues[1] val srcMatch = Regex("""src\s*=\s*(["'])([^"']+)\1""").find(imgTag) val src = srcMatch?.groupValues?.get(2) ?: "" val altMatch = Regex("""alt\s*=\s*(["'])([^"']*)\1""").find(imgTag) val alt = altMatch?.groupValues?.get(2) ?: "" if (src.isNotEmpty()) { val normalizedSrc = resolveUrl(src) if (shouldSkipImage(normalizedSrc)) { if (alt.isNotEmpty()) "**$alt**" else "" } else { "![$alt]($normalizedSrc)" } } else { "" } } // 4. Normalize markdown image URLs (resolve relative, normalize GitHub blob) processed = processed.replace( Regex("""!\[([^\]]*)\]\(([^)]+)\)"""), ) { match -> val alt = match.groupValues[1] val originalPath = match.groupValues[2].trim() val finalUrl = resolveUrl(originalPath) if (shouldSkipImage(finalUrl)) { if (alt.isNotEmpty()) "**$alt**" else "" } else { "![$alt]($finalUrl)" } } // 5. Handle """, setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL), ), ) { match -> val src = match.groupValues[2] "[Video](${resolveUrl(src)})" } // Video with inside processed = processed.replace( Regex( """]*>.*?]*?\ssrc=(["'])([^"']+)\1[^>]*?>.*?""", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL), ), ) { match -> val src = match.groupValues[2] "[Video](${resolveUrl(src)})" } // 6. Convert HTML headings

→ markdown headings for (level in 1..6) { val hashes = "#".repeat(level) processed = processed.replace( Regex( """]*>(.*?)""", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL), ), ) { match -> val content = match.groupValues[1].trim() "\n$hashes $content\n" } } // 7. Convert
and
tags processed = processed.replace( Regex("""""", RegexOption.IGNORE_CASE), "\n", ) processed = processed.replace( Regex("""""", RegexOption.IGNORE_CASE), "\n---\n", ) // 8. Convert inline formatting tags // / → **text** processed = processed.replace( Regex( """<(b|strong)>(.*?)""", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL), ), ) { match -> "**${match.groupValues[2]}**" } // / → *text* processed = processed.replace( Regex( """<(i|em)>(.*?)""", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL), ), ) { match -> "*${match.groupValues[2]}*" } // → `text` (single-line only, not
)
    processed =
        processed.replace(
            Regex(
                """([^<]*?)""",
                RegexOption.IGNORE_CASE,
            ),
        ) { match ->
            "`${match.groupValues[1]}`"
        }
    //  /  /  → ~~text~~
    processed =
        processed.replace(
            Regex(
                """<(s|del|strike)>(.*?)""",
                setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL),
            ),
        ) { match ->
            "~~${match.groupValues[2]}~~"
        }

    // 9. Convert text → [text](url) (non-image links)
    processed =
        processed.replace(
            Regex(
                """]*?href\s*=\s*(["'])([^"']+)\1[^>]*>(.*?)""",
                setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL),
            ),
        ) { match ->
            val url = match.groupValues[2]
            val text = match.groupValues[3].trim()
            val resolvedUrl = resolveUrl(url)
            if (text.isEmpty()) {
                "[$resolvedUrl]($resolvedUrl)"
            } else {
                "[$text]($resolvedUrl)"
            }
        }

    // 10.  → `text`
    processed =
        processed.replace(
            Regex(
                """(.*?)""",
                setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL),
            ),
        ) { match ->
            "`${match.groupValues[1]}`"
        }

    // 11. Strip remaining wrapper tags (keep content)
    // 
tags processed = processed.replace( Regex("""]*?>\s*""", RegexOption.IGNORE_CASE), "\n\n", ) processed = processed.replace( Regex("""
\s*""", RegexOption.IGNORE_CASE), "\n\n", ) //

/

processed = processed.replace( Regex("""]*?>""", RegexOption.IGNORE_CASE), "\n", ) processed = processed.replace( Regex("""

""", RegexOption.IGNORE_CASE), "\n", ) //
/ processed = processed.replace( Regex("""]*?>""", RegexOption.IGNORE_CASE), "\n", ) processed = processed.replace( Regex("""
""", RegexOption.IGNORE_CASE), "\n", ) processed = processed.replace( Regex( """]*?>(.*?)""", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL), ), ) { match -> "**${match.groupValues[1].trim()}**\n" } // , , — strip tags, keep content processed = processed.replace( Regex("""]*?>""", RegexOption.IGNORE_CASE), "", ) // Strip other common straggler HTML tags processed = processed.replace( Regex( """]*?>""", RegexOption.IGNORE_CASE, ), "\n", ) // 12. Decode common HTML entities processed = processed .replace("&", "&") .replace("<", "<") .replace(">", ">") .replace(""", "\"") .replace("'", "'") .replace("'", "'") .replace(" ", " ") // Numeric HTML entities processed = processed.replace(Regex("""&#(\d+);""")) { match -> val code = match.groupValues[1].toIntOrNull() if (code != null && code in 32..126) { code.toChar().toString() } else { match.value } } // 13. Clean up empty

tags and excess newlines processed = processed.replace( Regex("""]*?>\s*

""", RegexOption.IGNORE_CASE), "", ) processed = processed.replace( Regex("""\n{3,}"""), "\n\n", ) // 14. Clean up orphaned markdown link fragments processed = processed.replace( Regex("""^\]\([^)]+\)""", RegexOption.MULTILINE), "", ) return processed.trim() } ================================================ FILE: feature/details/domain/.gitignore ================================================ /build ================================================ FILE: feature/details/domain/build.gradle.kts ================================================ plugins { alias(libs.plugins.convention.kmp.library) } kotlin { sourceSets { commonMain { dependencies { implementation(libs.kotlin.stdlib) implementation(projects.core.domain) } } androidMain { dependencies { } } jvmMain { dependencies { } } } } ================================================ FILE: feature/details/domain/src/androidMain/AndroidManifest.xml ================================================ ================================================ FILE: feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/ReleaseCategory.kt ================================================ package zed.rainxch.details.domain.model enum class ReleaseCategory { STABLE, PRE_RELEASE, ALL, } ================================================ FILE: feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/RepoStats.kt ================================================ package zed.rainxch.details.domain.model import kotlinx.serialization.Serializable @Serializable data class RepoStats( val stars: Int, val forks: Int, val openIssues: Int, ) ================================================ FILE: feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/SupportedLanguage.kt ================================================ package zed.rainxch.details.domain.model data class SupportedLanguage( val code: String, val displayName: String, ) ================================================ FILE: feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/TranslationResult.kt ================================================ package zed.rainxch.details.domain.model data class TranslationResult( val translatedText: String, val detectedSourceLanguage: String?, ) ================================================ FILE: feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/repository/DetailsRepository.kt ================================================ package zed.rainxch.details.domain.repository import zed.rainxch.core.domain.model.GithubRelease import zed.rainxch.core.domain.model.GithubRepoSummary import zed.rainxch.core.domain.model.GithubUserProfile import zed.rainxch.details.domain.model.RepoStats typealias ReadmeContent = String typealias ReadmePath = String typealias LanguageCode = String interface DetailsRepository { suspend fun getRepositoryById(id: Long): GithubRepoSummary suspend fun getRepositoryByOwnerAndName( owner: String, name: String, ): GithubRepoSummary suspend fun getLatestPublishedRelease( owner: String, repo: String, defaultBranch: String, ): GithubRelease? suspend fun getAllReleases( owner: String, repo: String, defaultBranch: String, ): List suspend fun getReadme( owner: String, repo: String, defaultBranch: String, ): Triple? suspend fun getRepoStats( owner: String, repo: String, ): RepoStats suspend fun getUserProfile(username: String): GithubUserProfile suspend fun checkAttestations( owner: String, repo: String, sha256Digest: String, ): Boolean } ================================================ FILE: feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/repository/TranslationRepository.kt ================================================ package zed.rainxch.details.domain.repository import zed.rainxch.details.domain.model.TranslationResult interface TranslationRepository { suspend fun translate( text: String, targetLanguage: String, sourceLanguage: String = "auto", ): TranslationResult fun getDeviceLanguageCode(): String } ================================================ FILE: feature/details/presentation/.gitignore ================================================ /build ================================================ FILE: feature/details/presentation/build.gradle.kts ================================================ plugins { alias(libs.plugins.convention.cmp.feature) } kotlin { sourceSets { commonMain { dependencies { implementation(libs.kotlin.stdlib) implementation(projects.core.domain) implementation(projects.core.presentation) implementation(projects.feature.details.domain) implementation(libs.markdown.renderer) implementation(libs.markdown.renderer.coil3) implementation(compose.components.resources) implementation(libs.liquid) implementation(libs.kotlinx.datetime) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.bundles.landscapist) } } androidMain { dependencies { } } jvmMain { dependencies { } } } } ================================================ FILE: feature/details/presentation/src/androidMain/AndroidManifest.xml ================================================ ================================================ FILE: feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt ================================================ package zed.rainxch.details.presentation import org.jetbrains.compose.resources.StringResource import zed.rainxch.core.domain.model.GithubAsset import zed.rainxch.core.domain.model.GithubRelease import zed.rainxch.details.domain.model.ReleaseCategory import zed.rainxch.details.presentation.model.TranslationTarget sealed interface DetailsAction { data object Retry : DetailsAction data object InstallPrimary : DetailsAction data object OnDismissDowngradeWarning : DetailsAction data object OnDismissSigningKeyWarning : DetailsAction data object OnOverrideSigningKeyWarning : DetailsAction data object UninstallApp : DetailsAction data object OnRequestUninstall : DetailsAction data object OnDismissUninstallConfirmation : DetailsAction data object OnConfirmUninstall : DetailsAction data class DownloadAsset( val downloadUrl: String, val assetName: String, val sizeBytes: Long, ) : DetailsAction data object CancelCurrentDownload : DetailsAction data object OpenRepoInBrowser : DetailsAction data object OpenAuthorInBrowser : DetailsAction data class OpenDeveloperProfile( val username: String, ) : DetailsAction data object OpenInObtainium : DetailsAction data object OpenInAppManager : DetailsAction data object InstallWithExternalApp : DetailsAction data object OpenWithExternalInstaller : DetailsAction data object DismissExternalInstallerPrompt : DetailsAction data object OnToggleInstallDropdown : DetailsAction data object OnNavigateBackClick : DetailsAction data object OnToggleFavorite : DetailsAction data object OnShareClick : DetailsAction data object UpdateApp : DetailsAction data object OpenApp : DetailsAction data class OnMessage( val messageText: StringResource, ) : DetailsAction data class SelectReleaseCategory( val category: ReleaseCategory, ) : DetailsAction data class SelectRelease( val release: GithubRelease, ) : DetailsAction data object ToggleVersionPicker : DetailsAction data object ToggleAboutExpanded : DetailsAction data object ToggleWhatsNewExpanded : DetailsAction data class TranslateAbout( val targetLanguageCode: String, ) : DetailsAction data class TranslateWhatsNew( val targetLanguageCode: String, ) : DetailsAction data object ToggleAboutTranslation : DetailsAction data object ToggleWhatsNewTranslation : DetailsAction data class ShowLanguagePicker( val target: TranslationTarget, ) : DetailsAction data object DismissLanguagePicker : DetailsAction // show release asset picker data class SelectDownloadAsset( val release: GithubAsset, ) : DetailsAction data object ToggleReleaseAssetsPicker : DetailsAction } ================================================ FILE: feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsEvent.kt ================================================ package zed.rainxch.details.presentation sealed interface DetailsEvent { data class OnOpenRepositoryInApp( val repositoryId: Long, ) : DetailsEvent data class OnMessage( val message: String, ) : DetailsEvent } ================================================ FILE: feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt ================================================ package zed.rainxch.details.presentation import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.CutCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.FavoriteBorder import androidx.compose.material.icons.filled.OpenInBrowser import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.StarBorder import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.github.fletchmckee.liquid.LiquidState import io.github.fletchmckee.liquid.liquefiable import io.github.fletchmckee.liquid.liquid import io.github.fletchmckee.liquid.rememberLiquidState import kotlinx.coroutines.launch import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import zed.rainxch.core.presentation.theme.GithubStoreTheme import zed.rainxch.core.presentation.utils.ObserveAsEvents import zed.rainxch.core.presentation.utils.isLiquidFrostAvailable import zed.rainxch.details.presentation.components.LanguagePicker import zed.rainxch.details.presentation.components.sections.about import zed.rainxch.details.presentation.components.sections.author import zed.rainxch.details.presentation.components.sections.header import zed.rainxch.details.presentation.components.sections.logs import zed.rainxch.details.presentation.components.sections.reportIssue import zed.rainxch.details.presentation.components.sections.stats import zed.rainxch.details.presentation.components.sections.whatsNew import zed.rainxch.details.presentation.components.states.ErrorState import zed.rainxch.details.presentation.model.TranslationTarget import zed.rainxch.details.presentation.utils.LocalTopbarLiquidState import zed.rainxch.githubstore.core.presentation.res.Res import zed.rainxch.githubstore.core.presentation.res.add_to_favourites import zed.rainxch.githubstore.core.presentation.res.cancel import zed.rainxch.githubstore.core.presentation.res.confirm_uninstall_message import zed.rainxch.githubstore.core.presentation.res.confirm_uninstall_title import zed.rainxch.githubstore.core.presentation.res.dismiss import zed.rainxch.githubstore.core.presentation.res.downgrade_requires_uninstall import zed.rainxch.githubstore.core.presentation.res.downgrade_warning_message import zed.rainxch.githubstore.core.presentation.res.install_anyway import zed.rainxch.githubstore.core.presentation.res.install_permission_blocked_message import zed.rainxch.githubstore.core.presentation.res.install_permission_unavailable import zed.rainxch.githubstore.core.presentation.res.navigate_back import zed.rainxch.githubstore.core.presentation.res.open_repository import zed.rainxch.githubstore.core.presentation.res.open_with_external_installer import zed.rainxch.githubstore.core.presentation.res.remove_from_favourites import zed.rainxch.githubstore.core.presentation.res.repository_not_starred import zed.rainxch.githubstore.core.presentation.res.repository_starred import zed.rainxch.githubstore.core.presentation.res.share_repository import zed.rainxch.githubstore.core.presentation.res.signing_key_changed_message import zed.rainxch.githubstore.core.presentation.res.signing_key_changed_title import zed.rainxch.githubstore.core.presentation.res.star_from_github import zed.rainxch.githubstore.core.presentation.res.uninstall import zed.rainxch.githubstore.core.presentation.res.uninstall_first import zed.rainxch.githubstore.core.presentation.res.unstar_from_github @Composable fun DetailsRoot( onNavigateBack: () -> Unit, onNavigateToDeveloperProfile: (username: String) -> Unit, onOpenRepositoryInApp: (repoId: Long) -> Unit, viewModel: DetailsViewModel = koinViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } val coroutineScope = rememberCoroutineScope() ObserveAsEvents(viewModel.events) { event -> when (event) { is DetailsEvent.OnOpenRepositoryInApp -> { onOpenRepositoryInApp(event.repositoryId) } is DetailsEvent.OnMessage -> { coroutineScope.launch { snackbarHostState.showSnackbar(event.message) } } } } DetailsScreen( state = state, snackbarHostState = snackbarHostState, onAction = { action -> when (action) { DetailsAction.OnNavigateBackClick -> { onNavigateBack() } is DetailsAction.OpenDeveloperProfile -> { onNavigateToDeveloperProfile(action.username) } is DetailsAction.OnMessage -> { coroutineScope.launch { snackbarHostState.showSnackbar(getString(action.messageText)) } } else -> { viewModel.onAction(action) } } }, ) state.downgradeWarning?.let { warning -> AlertDialog( onDismissRequest = { viewModel.onAction(DetailsAction.OnDismissDowngradeWarning) }, title = { Text( text = stringResource(Res.string.downgrade_requires_uninstall), ) }, text = { Text( text = stringResource( Res.string.downgrade_warning_message, warning.targetVersion, warning.currentVersion, ), ) }, confirmButton = { TextButton( onClick = { viewModel.onAction(DetailsAction.OnDismissDowngradeWarning) viewModel.onAction(DetailsAction.UninstallApp) }, ) { Text( text = stringResource(Res.string.uninstall_first), color = MaterialTheme.colorScheme.error, ) } }, dismissButton = { TextButton( onClick = { viewModel.onAction(DetailsAction.OnDismissDowngradeWarning) }, ) { Text( text = stringResource(Res.string.cancel), ) } }, ) } // Signing key changed warning dialog state.signingKeyWarning?.let { warning -> AlertDialog( onDismissRequest = { viewModel.onAction(DetailsAction.OnDismissSigningKeyWarning) }, title = { Text( text = stringResource(Res.string.signing_key_changed_title), ) }, text = { Text( text = stringResource( Res.string.signing_key_changed_message, warning.expectedFingerprint.take(19), warning.actualFingerprint.take(19), ), ) }, confirmButton = { TextButton( onClick = { viewModel.onAction(DetailsAction.OnOverrideSigningKeyWarning) }, ) { Text( text = stringResource(Res.string.install_anyway), color = MaterialTheme.colorScheme.error, ) } }, dismissButton = { TextButton( onClick = { viewModel.onAction(DetailsAction.OnDismissSigningKeyWarning) }, ) { Text( text = stringResource(Res.string.cancel), ) } }, ) } // Uninstall confirmation dialog if (state.showUninstallConfirmation) { val appName = state.installedApp?.appName ?: "" AlertDialog( onDismissRequest = { viewModel.onAction(DetailsAction.OnDismissUninstallConfirmation) }, title = { Text( text = stringResource(Res.string.confirm_uninstall_title), ) }, text = { Text( text = stringResource(Res.string.confirm_uninstall_message, appName), ) }, confirmButton = { TextButton( onClick = { viewModel.onAction(DetailsAction.OnConfirmUninstall) }, ) { Text( text = stringResource(Res.string.uninstall), color = MaterialTheme.colorScheme.error, ) } }, dismissButton = { TextButton( onClick = { viewModel.onAction(DetailsAction.OnDismissUninstallConfirmation) }, ) { Text(text = stringResource(Res.string.cancel)) } }, ) } if (state.showExternalInstallerPrompt) { AlertDialog( onDismissRequest = { viewModel.onAction(DetailsAction.DismissExternalInstallerPrompt) }, title = { Text(text = stringResource(Res.string.install_permission_unavailable)) }, text = { Text(text = stringResource(Res.string.install_permission_blocked_message)) }, confirmButton = { TextButton( onClick = { viewModel.onAction(DetailsAction.OpenWithExternalInstaller) }, ) { Text(text = stringResource(Res.string.open_with_external_installer)) } }, dismissButton = { TextButton( onClick = { viewModel.onAction(DetailsAction.DismissExternalInstallerPrompt) }, ) { Text(text = stringResource(Res.string.dismiss)) } }, ) } } @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun DetailsScreen( state: DetailsState, onAction: (DetailsAction) -> Unit, snackbarHostState: SnackbarHostState, ) { val liquidTopbarState = rememberLiquidState() CompositionLocalProvider( value = LocalTopbarLiquidState provides liquidTopbarState, ) { Scaffold( topBar = { DetailsTopbar( state = state, onAction = onAction, liquidTopbarState = liquidTopbarState, ) }, snackbarHost = { SnackbarHost( hostState = snackbarHostState, ) }, containerColor = MaterialTheme.colorScheme.background, modifier = Modifier.then( if (state.isLiquidGlassEnabled) { Modifier.liquefiable(liquidTopbarState) } else { Modifier }, ), ) { innerPadding -> LanguagePicker( isVisible = state.isLanguagePickerVisible, selectedLanguageCode = when (state.languagePickerTarget) { TranslationTarget.About -> state.aboutTranslation.targetLanguageCode TranslationTarget.WhatsNew -> state.whatsNewTranslation.targetLanguageCode null -> null }, deviceLanguageCode = state.deviceLanguageCode, onLanguageSelected = { language -> when (state.languagePickerTarget) { TranslationTarget.About -> { onAction(DetailsAction.TranslateAbout(language.code)) } TranslationTarget.WhatsNew -> { onAction( DetailsAction.TranslateWhatsNew( language.code, ), ) } null -> {} } onAction(DetailsAction.DismissLanguagePicker) }, onDismiss = { onAction(DetailsAction.DismissLanguagePicker) }, ) if (state.isLoading) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { CircularWavyProgressIndicator() } return@Scaffold } if (state.errorMessage != null) { ErrorState(state.errorMessage, onAction) return@Scaffold } BoxWithConstraints( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { val collapsedSectionHeight = maxHeight * 0.7f LazyColumn( modifier = Modifier .fillMaxHeight() .widthIn(max = 680.dp) .fillMaxWidth() .then( if (state.isLiquidGlassEnabled) { Modifier.liquefiable(liquidTopbarState) } else { Modifier }, ).padding(innerPadding), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(24.dp), ) { header( state = state, onAction = onAction, ) state.stats?.let { stats -> stats( isLiquidGlassEnabled = state.isLiquidGlassEnabled, repoStats = stats, ) } if (state.isComingFromUpdate) { state.selectedRelease?.let { release -> whatsNew( release = release, isExpanded = state.isWhatsNewExpanded, isLiquidGlassEnabled = state.isLiquidGlassEnabled, onToggleExpanded = { onAction(DetailsAction.ToggleWhatsNewExpanded) }, collapsedHeight = collapsedSectionHeight, translationState = state.whatsNewTranslation, onTranslateClick = { onAction(DetailsAction.TranslateWhatsNew(state.deviceLanguageCode)) }, onLanguagePickerClick = { onAction(DetailsAction.ShowLanguagePicker(TranslationTarget.WhatsNew)) }, onToggleTranslation = { onAction(DetailsAction.ToggleWhatsNewTranslation) }, ) } state.readmeMarkdown?.let { about( readmeMarkdown = state.readmeMarkdown, readmeLanguage = state.readmeLanguage, isExpanded = state.isAboutExpanded, isLiquidGlassEnabled = state.isLiquidGlassEnabled, onToggleExpanded = { onAction(DetailsAction.ToggleAboutExpanded) }, collapsedHeight = collapsedSectionHeight, translationState = state.aboutTranslation, onTranslateClick = { onAction(DetailsAction.TranslateAbout(state.deviceLanguageCode)) }, onLanguagePickerClick = { onAction(DetailsAction.ShowLanguagePicker(TranslationTarget.About)) }, onToggleTranslation = { onAction(DetailsAction.ToggleAboutTranslation) }, ) } } else { state.readmeMarkdown?.let { about( readmeMarkdown = state.readmeMarkdown, readmeLanguage = state.readmeLanguage, isExpanded = state.isAboutExpanded, isLiquidGlassEnabled = state.isLiquidGlassEnabled, onToggleExpanded = { onAction(DetailsAction.ToggleAboutExpanded) }, collapsedHeight = collapsedSectionHeight, translationState = state.aboutTranslation, onTranslateClick = { onAction(DetailsAction.TranslateAbout(state.deviceLanguageCode)) }, onLanguagePickerClick = { onAction(DetailsAction.ShowLanguagePicker(TranslationTarget.About)) }, onToggleTranslation = { onAction(DetailsAction.ToggleAboutTranslation) }, ) } state.selectedRelease?.let { release -> whatsNew( release = release, isExpanded = state.isWhatsNewExpanded, isLiquidGlassEnabled = state.isLiquidGlassEnabled, onToggleExpanded = { onAction(DetailsAction.ToggleWhatsNewExpanded) }, collapsedHeight = collapsedSectionHeight, translationState = state.whatsNewTranslation, onTranslateClick = { onAction(DetailsAction.TranslateWhatsNew(state.deviceLanguageCode)) }, onLanguagePickerClick = { onAction(DetailsAction.ShowLanguagePicker(TranslationTarget.WhatsNew)) }, onToggleTranslation = { onAction(DetailsAction.ToggleWhatsNewTranslation) }, ) } } state.repository?.let { repository -> reportIssue( repoUrl = repository.htmlUrl, ) } state.userProfile?.let { userProfile -> author( isLiquidGlassEnabled = state.isLiquidGlassEnabled, author = userProfile, onAction = onAction, ) } if (state.installLogs.isNotEmpty()) { logs(state) } } } } } } @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable private fun DetailsTopbar( state: DetailsState, onAction: (DetailsAction) -> Unit, liquidTopbarState: LiquidState, ) { TopAppBar( title = { }, navigationIcon = { IconButton( shapes = IconButtonDefaults.shapes(), onClick = { onAction(DetailsAction.OnNavigateBackClick) }, ) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.navigate_back), modifier = Modifier.size(24.dp), ) } }, actions = { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { if (state.repository != null) { IconButton( onClick = { onAction( DetailsAction.OnMessage( messageText = if (state.isStarred) { Res.string.unstar_from_github } else { Res.string.star_from_github }, ), ) }, shapes = IconButtonDefaults.shapes(), colors = IconButtonDefaults.iconButtonColors( contentColor = MaterialTheme.colorScheme.onSurface, ), ) { Icon( imageVector = if (state.isStarred) { Icons.Default.Star } else { Icons.Default.StarBorder }, contentDescription = stringResource( resource = if (state.isStarred) { Res.string.repository_starred } else { Res.string.repository_not_starred }, ), ) } IconButton( onClick = { onAction(DetailsAction.OnToggleFavorite) }, shapes = IconButtonDefaults.shapes(), colors = IconButtonDefaults.iconButtonColors( contentColor = MaterialTheme.colorScheme.onSurface, ), ) { Icon( imageVector = if (state.isFavourite) { Icons.Default.Favorite } else { Icons.Default.FavoriteBorder }, contentDescription = stringResource( resource = if (state.isFavourite) { Res.string.remove_from_favourites } else { Res.string.add_to_favourites }, ), ) } IconButton( onClick = { onAction(DetailsAction.OnShareClick) }, shapes = IconButtonDefaults.shapes(), colors = IconButtonDefaults.iconButtonColors( contentColor = MaterialTheme.colorScheme.onSurface, ), ) { Icon( imageVector = Icons.Default.Share, contentDescription = stringResource(Res.string.share_repository), ) } } state.repository?.htmlUrl?.let { IconButton( shapes = IconButtonDefaults.shapes(), onClick = { onAction(DetailsAction.OpenRepoInBrowser) }, colors = IconButtonDefaults.iconButtonColors( contentColor = MaterialTheme.colorScheme.onSurface, ), ) { Icon( imageVector = Icons.Default.OpenInBrowser, contentDescription = stringResource(Res.string.open_repository), modifier = Modifier.size(24.dp), ) } } } }, colors = TopAppBarDefaults.topAppBarColors( containerColor = Color.Transparent, ), modifier = Modifier .shadow( elevation = 6.dp, ambientColor = MaterialTheme.colorScheme.surfaceTint, spotColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f), ).background( Brush.linearGradient( 0f to MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), 0.5f to MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f), 1f to MaterialTheme.colorScheme.surface.copy(alpha = 0.85f), ), ).then( if (state.isLiquidGlassEnabled && isLiquidFrostAvailable()) { Modifier.liquid(liquidTopbarState) { this.shape = CutCornerShape(0.dp) this.frost = 5.dp this.curve = .25f this.refraction = .05f this.dispersion = .1f } } else { Modifier.background(MaterialTheme.colorScheme.surfaceContainerHighest) }, ), ) } @Preview @Composable private fun Preview() { GithubStoreTheme { DetailsScreen( state = DetailsState( isLoading = false, ), onAction = {}, snackbarHostState = SnackbarHostState(), ) } } ================================================ FILE: feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt ================================================ package zed.rainxch.details.presentation import zed.rainxch.core.domain.model.GithubAsset import zed.rainxch.core.domain.model.GithubRelease import zed.rainxch.core.domain.model.GithubRepoSummary import zed.rainxch.core.domain.model.GithubUserProfile import zed.rainxch.core.domain.model.InstalledApp import zed.rainxch.core.domain.model.SystemArchitecture import zed.rainxch.details.domain.model.ReleaseCategory import zed.rainxch.details.domain.model.RepoStats import zed.rainxch.details.presentation.model.AttestationStatus import zed.rainxch.details.presentation.model.DowngradeWarning import zed.rainxch.details.presentation.model.DownloadStage import zed.rainxch.details.presentation.model.InstallLogItem import zed.rainxch.details.presentation.model.SigningKeyWarning import zed.rainxch.details.presentation.model.TranslationState import zed.rainxch.details.presentation.model.TranslationTarget data class DetailsState( val isLoading: Boolean = true, val errorMessage: String? = null, val userProfile: GithubUserProfile? = null, val repository: GithubRepoSummary? = null, // state for assets val primaryAsset: GithubAsset? = null, val installableAssets: List = emptyList(), // state for releases val selectedRelease: GithubRelease? = null, val allReleases: List = emptyList(), val isReleaseSelectorVisible: Boolean = false, val selectedReleaseCategory: ReleaseCategory = ReleaseCategory.STABLE, val isVersionPickerVisible: Boolean = false, val stats: RepoStats? = null, val readmeMarkdown: String? = null, val readmeLanguage: String? = null, val installLogs: List = emptyList(), val isDownloading: Boolean = false, val downloadProgressPercent: Int? = null, val downloadedBytes: Long = 0L, val totalBytes: Long? = null, val isInstalling: Boolean = false, val downloadError: String? = null, val installError: String? = null, val downloadStage: DownloadStage = DownloadStage.IDLE, val systemArchitecture: SystemArchitecture = SystemArchitecture.UNKNOWN, val isObtainiumAvailable: Boolean = false, val isObtainiumEnabled: Boolean = false, val isInstallDropdownExpanded: Boolean = false, val isAppManagerAvailable: Boolean = false, val isAppManagerEnabled: Boolean = false, val installedApp: InstalledApp? = null, val isFavourite: Boolean = false, val isStarred: Boolean = false, val isTrackingApp: Boolean = false, val isAboutExpanded: Boolean = false, val isWhatsNewExpanded: Boolean = false, val isLiquidGlassEnabled: Boolean = true, val aboutTranslation: TranslationState = TranslationState(), val whatsNewTranslation: TranslationState = TranslationState(), val isLanguagePickerVisible: Boolean = false, val languagePickerTarget: TranslationTarget? = null, val deviceLanguageCode: String = "en", val isComingFromUpdate: Boolean = false, val downgradeWarning: DowngradeWarning? = null, val signingKeyWarning: SigningKeyWarning? = null, val showExternalInstallerPrompt: Boolean = false, val pendingInstallFilePath: String? = null, val showUninstallConfirmation: Boolean = false, val attestationStatus: AttestationStatus = AttestationStatus.UNCHECKED, ) { val filteredReleases: List get() = when (selectedReleaseCategory) { ReleaseCategory.STABLE -> allReleases.filter { !it.isPrerelease } ReleaseCategory.PRE_RELEASE -> allReleases.filter { it.isPrerelease } ReleaseCategory.ALL -> allReleases } } ================================================ FILE: feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt ================================================ package zed.rainxch.details.presentation import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone import kotlinx.datetime.format import kotlinx.datetime.format.char import kotlinx.datetime.toLocalDateTime import org.jetbrains.compose.resources.getString import zed.rainxch.core.domain.logging.GitHubStoreLogger import zed.rainxch.core.domain.model.ApkPackageInfo import zed.rainxch.core.domain.model.FavoriteRepo import zed.rainxch.core.domain.model.GithubAsset import zed.rainxch.core.domain.model.GithubRelease import zed.rainxch.core.domain.model.InstallSource import zed.rainxch.core.domain.model.InstalledApp import zed.rainxch.core.domain.model.Platform import zed.rainxch.core.domain.model.RateLimitException import zed.rainxch.core.domain.network.Downloader import zed.rainxch.core.domain.repository.FavouritesRepository import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.repository.SeenReposRepository import zed.rainxch.core.domain.repository.StarredRepository import zed.rainxch.core.domain.repository.TweaksRepository import zed.rainxch.core.domain.system.Installer import zed.rainxch.core.domain.system.PackageMonitor import zed.rainxch.core.domain.use_cases.SyncInstalledAppsUseCase import zed.rainxch.core.domain.utils.BrowserHelper import zed.rainxch.core.domain.utils.ShareManager import zed.rainxch.details.domain.model.ReleaseCategory import zed.rainxch.details.domain.repository.DetailsRepository import zed.rainxch.details.domain.repository.TranslationRepository import zed.rainxch.details.presentation.model.AttestationStatus import zed.rainxch.details.presentation.model.DowngradeWarning import zed.rainxch.details.presentation.model.DownloadStage import zed.rainxch.details.presentation.model.InstallLogItem import zed.rainxch.details.presentation.model.LogResult import zed.rainxch.details.presentation.model.LogResult.Error import zed.rainxch.details.presentation.model.SigningKeyWarning import zed.rainxch.details.presentation.model.SupportedLanguages import zed.rainxch.details.presentation.model.TranslationState import zed.rainxch.githubstore.core.presentation.res.Res import zed.rainxch.githubstore.core.presentation.res.added_to_favourites import zed.rainxch.githubstore.core.presentation.res.failed_to_open_app import zed.rainxch.githubstore.core.presentation.res.failed_to_share_link import zed.rainxch.githubstore.core.presentation.res.failed_to_uninstall import zed.rainxch.githubstore.core.presentation.res.installer_saved_downloads import zed.rainxch.githubstore.core.presentation.res.link_copied_to_clipboard import zed.rainxch.githubstore.core.presentation.res.rate_limit_exceeded import zed.rainxch.githubstore.core.presentation.res.removed_from_favourites import zed.rainxch.githubstore.core.presentation.res.translation_failed import zed.rainxch.githubstore.core.presentation.res.update_package_mismatch import java.io.File import java.io.FileInputStream import java.security.MessageDigest import java.util.concurrent.atomic.AtomicBoolean import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Clock.System import kotlin.time.ExperimentalTime class DetailsViewModel( private val repositoryId: Long, private val ownerParam: String, private val repoParam: String, private val detailsRepository: DetailsRepository, private val downloader: Downloader, private val installer: Installer, private val platform: Platform, private val helper: BrowserHelper, private val shareManager: ShareManager, private val installedAppsRepository: InstalledAppsRepository, private val favouritesRepository: FavouritesRepository, private val starredRepository: StarredRepository, private val packageMonitor: PackageMonitor, private val syncInstalledAppsUseCase: SyncInstalledAppsUseCase, private val translationRepository: TranslationRepository, private val logger: GitHubStoreLogger, private val isComingFromUpdate: Boolean, private val tweaksRepository: TweaksRepository, private val seenReposRepository: SeenReposRepository, ) : ViewModel() { private var hasLoadedInitialData = false private var currentDownloadJob: Job? = null private var currentAssetName: String? = null private var aboutTranslationJob: Job? = null private var whatsNewTranslationJob: Job? = null private var cachedDownloadAssetName: String? = null private val _state = MutableStateFlow(DetailsState()) val state = _state .onStart { if (!hasLoadedInitialData) { loadInitial() observeLiquidGlassEnabled() hasLoadedInitialData = true } }.stateIn( viewModelScope, SharingStarted.WhileSubscribed(5000), DetailsState(), ) private val _events = Channel() val events = _events.receiveAsFlow() private val rateLimited = AtomicBoolean(false) private fun observeLiquidGlassEnabled() { viewModelScope.launch { tweaksRepository.getLiquidGlassEnabled().collect { enabled -> _state.update { it.copy(isLiquidGlassEnabled = enabled) } } } } private fun recomputeAssetsForRelease(release: GithubRelease?): Pair, GithubAsset?> { val installable = release ?.assets ?.filter { asset -> installer.isAssetInstallable(asset.name) }.orEmpty() val primary = installer.choosePrimaryAsset(installable) return installable to primary } @OptIn(ExperimentalTime::class) private fun loadInitial() { viewModelScope.launch { try { rateLimited.set(false) _state.value = _state.value.copy(isLoading = true, errorMessage = null) val syncResult = syncInstalledAppsUseCase() if (syncResult.isFailure) { logger.warn("Sync had issues but continuing: ${syncResult.exceptionOrNull()?.message}") } val repo = if (ownerParam.isNotEmpty() && repoParam.isNotEmpty()) { detailsRepository.getRepositoryByOwnerAndName(ownerParam, repoParam) } else { detailsRepository.getRepositoryById(repositoryId) } launch { seenReposRepository.markAsSeen(repo.id) } val isFavoriteDeferred = async { try { favouritesRepository.isFavoriteSync(repo.id) } catch (_: RateLimitException) { rateLimited.set(true) null } catch (t: Throwable) { logger.error("Failed to load if repo is favourite: ${t.localizedMessage}") false } } val isFavorite = isFavoriteDeferred.await() val isStarredDeferred = async { try { starredRepository.isStarred(repo.id) } catch (_: RateLimitException) { rateLimited.set(true) null } catch (t: Throwable) { logger.error("Failed to load if repo is starred: ${t.localizedMessage}") false } } val isStarred = isStarredDeferred.await() val owner = repo.owner.login val name = repo.name _state.value = _state.value.copy( repository = repo, isFavourite = isFavorite == true, isStarred = isStarred == true, ) val allReleasesDeferred = async { try { detailsRepository.getAllReleases( owner = owner, repo = name, defaultBranch = repo.defaultBranch, ) } catch (_: RateLimitException) { rateLimited.set(true) emptyList() } catch (t: Throwable) { logger.warn("Failed to load releases: ${t.message}") emptyList() } } val statsDeferred = async { try { detailsRepository.getRepoStats(owner, name) } catch (_: RateLimitException) { rateLimited.set(true) null } catch (_: Throwable) { null } } val readmeDeferred = async { try { detailsRepository.getReadme( owner = owner, repo = name, defaultBranch = repo.defaultBranch, ) } catch (_: RateLimitException) { rateLimited.set(true) null } catch (_: Throwable) { null } } val userProfileDeferred = async { try { detailsRepository.getUserProfile(owner) } catch (_: RateLimitException) { rateLimited.set(true) null } catch (t: Throwable) { logger.warn("Failed to load user profile: ${t.message}") null } } val installedAppDeferred = async { try { val dbApp = installedAppsRepository.getAppByRepoId(repo.id) if (dbApp != null) { if (dbApp.isPendingInstall && packageMonitor.isPackageInstalled(dbApp.packageName) ) { installedAppsRepository.updatePendingStatus( dbApp.packageName, false, ) installedAppsRepository.getAppByPackage(dbApp.packageName) } else { dbApp } } else { null } } catch (_: RateLimitException) { rateLimited.set(true) null } catch (t: Throwable) { logger.error("Failed to load installed app: ${t.message}") null } } val isObtainiumEnabled = platform == Platform.ANDROID val isAppManagerEnabled = platform == Platform.ANDROID val allReleases = allReleasesDeferred.await() val stats = statsDeferred.await() val readme = readmeDeferred.await() val userProfile = userProfileDeferred.await() val installedApp = installedAppDeferred.await() if (rateLimited.get()) { _state.value = _state.value.copy(isLoading = false, errorMessage = null) return@launch } val selectedRelease = allReleases.firstOrNull { !it.isPrerelease } ?: allReleases.firstOrNull() val (installable, primary) = recomputeAssetsForRelease(selectedRelease) val isObtainiumAvailable = installer.isObtainiumInstalled() val isAppManagerAvailable = installer.isAppManagerInstalled() logger.debug("Loaded repo: ${repo.name}, installedApp: ${installedApp?.packageName}") _state.value = _state.value.copy( isLoading = false, errorMessage = null, repository = repo, allReleases = allReleases, selectedRelease = selectedRelease, selectedReleaseCategory = ReleaseCategory.STABLE, stats = stats, readmeMarkdown = readme?.first, readmeLanguage = readme?.second, installableAssets = installable, primaryAsset = primary, userProfile = userProfile, systemArchitecture = installer.detectSystemArchitecture(), isObtainiumAvailable = isObtainiumAvailable, isObtainiumEnabled = isObtainiumEnabled, isAppManagerAvailable = isAppManagerAvailable, isAppManagerEnabled = isAppManagerEnabled, installedApp = installedApp, deviceLanguageCode = translationRepository.getDeviceLanguageCode(), isComingFromUpdate = isComingFromUpdate, ) observeInstalledApp(repo.id) } catch (e: RateLimitException) { logger.error("Rate limited: ${e.message}") _state.value = _state.value.copy( isLoading = false, errorMessage = getString(Res.string.rate_limit_exceeded), ) } catch (t: Throwable) { logger.error("Details load failed: ${t.message}") _state.value = _state.value.copy( isLoading = false, errorMessage = t.message ?: "Failed to load details", ) } } } private fun observeInstalledApp(repoId: Long) { viewModelScope.launch { installedAppsRepository .getAppByRepoIdAsFlow(repoId) .distinctUntilChanged() .collect { app -> _state.update { it.copy(installedApp = app) } } } } @OptIn(ExperimentalTime::class) fun onAction(action: DetailsAction) { when (action) { DetailsAction.Retry -> { hasLoadedInitialData = false loadInitial() } DetailsAction.OnDismissDowngradeWarning -> { _state.update { it.copy( downgradeWarning = null, ) } } DetailsAction.OnDismissSigningKeyWarning -> { _state.update { it.copy( signingKeyWarning = null, downloadStage = DownloadStage.IDLE, ) } currentAssetName = null } DetailsAction.OnOverrideSigningKeyWarning -> { val warning = _state.value.signingKeyWarning ?: return _state.update { it.copy(signingKeyWarning = null) } viewModelScope.launch { try { val ext = warning.pendingAssetName.substringAfterLast('.', "").lowercase() installer.install(warning.pendingFilePath, ext) if (platform == Platform.ANDROID) { saveInstalledAppToDatabase( assetName = warning.pendingAssetName, assetUrl = warning.pendingDownloadUrl, assetSize = warning.pendingSizeBytes, releaseTag = warning.pendingReleaseTag, isUpdate = warning.pendingIsUpdate, filePath = warning.pendingFilePath, ) } _state.value = _state.value.copy(downloadStage = DownloadStage.IDLE) currentAssetName = null appendLog( assetName = warning.pendingAssetName, size = warning.pendingSizeBytes, tag = warning.pendingReleaseTag, result = if (warning.pendingIsUpdate) LogResult.Updated else LogResult.Installed, ) } catch (t: Throwable) { logger.error("Install after override failed: ${t.message}") _state.value = _state.value.copy( downloadStage = DownloadStage.IDLE, installError = t.message, ) currentAssetName = null } } } DetailsAction.InstallPrimary -> { val primary = _state.value.primaryAsset val release = _state.value.selectedRelease val installedApp = _state.value.installedApp if (primary != null && release != null) { if (installedApp != null && !installedApp.isPendingInstall && normalizeVersion(release.tagName) != normalizeVersion(installedApp.installedVersion) && platform == Platform.ANDROID ) { val isDowngrade = isDowngradeVersion( candidate = release.tagName, current = installedApp.installedVersion, allReleases = _state.value.allReleases, ) if (isDowngrade) { _state.update { it.copy( downgradeWarning = DowngradeWarning( packageName = installedApp.packageName, currentVersion = installedApp.installedVersion, targetVersion = release.tagName, ), ) } return } } installAsset( downloadUrl = primary.downloadUrl, assetName = primary.name, sizeBytes = primary.size, releaseTag = release.tagName, ) } } DetailsAction.OnRequestUninstall -> { _state.update { it.copy(showUninstallConfirmation = true) } } DetailsAction.OnDismissUninstallConfirmation -> { _state.update { it.copy(showUninstallConfirmation = false) } } DetailsAction.OnConfirmUninstall -> { _state.update { it.copy(showUninstallConfirmation = false) } val installedApp = _state.value.installedApp ?: return logger.debug("Uninstalling app (confirmed): ${installedApp.packageName}") viewModelScope.launch { try { installer.uninstall(installedApp.packageName) } catch (e: Exception) { logger.error("Failed to request uninstall for ${installedApp.packageName}: ${e.message}") _events.send( DetailsEvent.OnMessage( getString(Res.string.failed_to_uninstall, installedApp.packageName), ), ) } } } DetailsAction.UninstallApp -> { // Legacy direct uninstall (used from downgrade warning flow) val installedApp = _state.value.installedApp ?: return logger.debug("Uninstalling app: ${installedApp.packageName}") viewModelScope.launch { try { installer.uninstall(installedApp.packageName) } catch (e: Exception) { logger.error("Failed to request uninstall for ${installedApp.packageName}: ${e.message}") _events.send( DetailsEvent.OnMessage( getString(Res.string.failed_to_uninstall, installedApp.packageName), ), ) } } } is DetailsAction.DownloadAsset -> { val release = _state.value.selectedRelease downloadAsset( downloadUrl = action.downloadUrl, assetName = action.assetName, sizeBytes = action.sizeBytes, releaseTag = release?.tagName ?: "", ) } DetailsAction.CancelCurrentDownload -> { currentDownloadJob?.cancel() currentDownloadJob = null val assetName = currentAssetName if (assetName != null) { cachedDownloadAssetName = assetName val releaseTag = _state.value.selectedRelease?.tagName ?: "" val totalSize = _state.value.totalBytes ?: _state.value.downloadedBytes appendLog( assetName = assetName, tag = releaseTag, size = totalSize, result = LogResult.Cancelled, ) logger.debug("Download cancelled – keeping file for potential reuse: $assetName") } currentAssetName = null _state.value = _state.value.copy( isDownloading = false, downloadProgressPercent = null, downloadStage = DownloadStage.IDLE, ) } DetailsAction.OnToggleFavorite -> { viewModelScope.launch { try { val repo = _state.value.repository ?: return@launch val selectedRelease = _state.value.selectedRelease val favoriteRepo = FavoriteRepo( repoId = repo.id, repoName = repo.name, repoOwner = repo.owner.login, repoOwnerAvatarUrl = repo.owner.avatarUrl, repoDescription = repo.description, primaryLanguage = repo.language, repoUrl = repo.htmlUrl, latestVersion = selectedRelease?.tagName, latestReleaseUrl = selectedRelease?.htmlUrl, addedAt = System.now().toEpochMilliseconds(), lastSyncedAt = System.now().toEpochMilliseconds(), ) favouritesRepository.toggleFavorite(favoriteRepo) val newFavoriteState = favouritesRepository.isFavoriteSync(repo.id) _state.value = _state.value.copy(isFavourite = newFavoriteState) _events.send( element = DetailsEvent.OnMessage( message = getString( resource = if (newFavoriteState) { Res.string.added_to_favourites } else { Res.string.removed_from_favourites }, ), ), ) } catch (t: Throwable) { logger.error("Failed to toggle favorite: ${t.message}") } } } DetailsAction.OnShareClick -> { viewModelScope.launch { _state.value.repository?.let { repo -> runCatching { shareManager.shareText("https://github-store.org/app?repo=${repo.fullName}") }.onFailure { t -> logger.error("Failed to share link: ${t.message}") _events.send( DetailsEvent.OnMessage(getString(Res.string.failed_to_share_link)), ) return@launch } if (platform != Platform.ANDROID) { _events.send(DetailsEvent.OnMessage(getString(Res.string.link_copied_to_clipboard))) } } } } DetailsAction.UpdateApp -> { val installedApp = _state.value.installedApp val selectedRelease = _state.value.selectedRelease if (installedApp != null && selectedRelease != null && installedApp.isUpdateAvailable) { val latestAsset = _state.value.installableAssets.firstOrNull { it.name == installedApp.latestAssetName } ?: _state.value.primaryAsset if (latestAsset != null) { installAsset( downloadUrl = latestAsset.downloadUrl, assetName = latestAsset.name, sizeBytes = latestAsset.size, releaseTag = selectedRelease.tagName, isUpdate = true, ) } } } DetailsAction.OpenApp -> { val installedApp = _state.value.installedApp ?: return val launched = installer.openApp(installedApp.packageName) if (!launched) { viewModelScope.launch { _events.send( DetailsEvent.OnMessage( getString( Res.string.failed_to_open_app, installedApp.appName, ), ), ) } } } DetailsAction.OpenRepoInBrowser -> { _state.value.repository?.htmlUrl?.let { helper.openUrl(url = it) } } DetailsAction.OpenAuthorInBrowser -> { _state.value.userProfile?.htmlUrl?.let { helper.openUrl(url = it) } } DetailsAction.OpenInObtainium -> { val repo = _state.value.repository repo?.owner?.login?.let { installer.openInObtainium( repoOwner = it, repoName = repo.name, onOpenInstaller = { viewModelScope.launch { _events.send( DetailsEvent.OnOpenRepositoryInApp(OBTAINIUM_REPO_ID), ) } }, ) } _state.update { it.copy(isInstallDropdownExpanded = false) } } DetailsAction.OpenInAppManager -> { viewModelScope.launch { try { val primary = _state.value.primaryAsset val release = _state.value.selectedRelease if (primary != null && release != null) { currentAssetName = primary.name appendLog( assetName = primary.name, size = primary.size, tag = release.tagName, result = LogResult.PreparingForAppManager, ) _state.value = _state.value.copy( downloadError = null, installError = null, downloadProgressPercent = null, downloadStage = DownloadStage.DOWNLOADING, ) downloader.download(primary.downloadUrl, primary.name).collect { p -> _state.value = _state.value.copy(downloadProgressPercent = p.percent) if (p.percent == 100) { _state.value = _state.value.copy(downloadStage = DownloadStage.VERIFYING) } } val filePath = downloader.getDownloadedFilePath(primary.name) ?: throw IllegalStateException("Downloaded file not found") appendLog( assetName = primary.name, size = primary.size, tag = release.tagName, result = LogResult.Downloaded, ) _state.value = _state.value.copy(downloadStage = DownloadStage.IDLE) currentAssetName = null installer.openInAppManager( filePath = filePath, onOpenInstaller = { viewModelScope.launch { _events.send( DetailsEvent.OnOpenRepositoryInApp(APP_MANAGER_REPO_ID), ) } }, ) appendLog( assetName = primary.name, size = primary.size, tag = release.tagName, result = LogResult.OpenedInAppManager, ) } } catch (t: Throwable) { logger.error("Failed to open in AppManager: ${t.message}") _state.value = _state.value.copy( downloadStage = DownloadStage.IDLE, installError = t.message, ) currentAssetName = null _state.value.primaryAsset?.let { asset -> _state.value.selectedRelease?.let { release -> appendLog( assetName = asset.name, size = asset.size, tag = release.tagName, result = LogResult.Error(t.message), ) } } } } _state.update { it.copy(isInstallDropdownExpanded = false) } } DetailsAction.OnToggleInstallDropdown -> { _state.update { it.copy(isInstallDropdownExpanded = !it.isInstallDropdownExpanded) } } is DetailsAction.SelectReleaseCategory -> { val newCategory = action.category val filtered = when (newCategory) { ReleaseCategory.STABLE -> _state.value.allReleases.filter { !it.isPrerelease } ReleaseCategory.PRE_RELEASE -> _state.value.allReleases.filter { it.isPrerelease } ReleaseCategory.ALL -> _state.value.allReleases } val newSelected = filtered.firstOrNull() val (installable, primary) = recomputeAssetsForRelease(newSelected) whatsNewTranslationJob?.cancel() _state.update { it.copy( selectedReleaseCategory = newCategory, selectedRelease = newSelected, installableAssets = installable, primaryAsset = primary, whatsNewTranslation = TranslationState(), ) } } is DetailsAction.SelectRelease -> { val release = action.release val (installable, primary) = recomputeAssetsForRelease(release) whatsNewTranslationJob?.cancel() _state.update { it.copy( selectedRelease = release, installableAssets = installable, primaryAsset = primary, isVersionPickerVisible = false, whatsNewTranslation = TranslationState(), ) } } DetailsAction.ToggleVersionPicker -> { _state.update { it.copy(isVersionPickerVisible = !it.isVersionPickerVisible) } } DetailsAction.ToggleAboutExpanded -> { _state.update { it.copy(isAboutExpanded = !it.isAboutExpanded) } } DetailsAction.ToggleWhatsNewExpanded -> { _state.update { it.copy(isWhatsNewExpanded = !it.isWhatsNewExpanded) } } is DetailsAction.TranslateAbout -> { val readme = _state.value.readmeMarkdown ?: return aboutTranslationJob?.cancel() aboutTranslationJob = translateContent( text = readme, targetLanguageCode = action.targetLanguageCode, updateState = { ts -> _state.update { it.copy(aboutTranslation = ts) } }, getCurrentState = { _state.value.aboutTranslation }, ) } is DetailsAction.TranslateWhatsNew -> { val description = _state.value.selectedRelease?.description ?: return whatsNewTranslationJob?.cancel() whatsNewTranslationJob = translateContent( text = description, targetLanguageCode = action.targetLanguageCode, updateState = { ts -> _state.update { it.copy(whatsNewTranslation = ts) } }, getCurrentState = { _state.value.whatsNewTranslation }, ) } DetailsAction.ToggleAboutTranslation -> { _state.update { val current = it.aboutTranslation it.copy(aboutTranslation = current.copy(isShowingTranslation = !current.isShowingTranslation)) } } DetailsAction.ToggleWhatsNewTranslation -> { _state.update { val current = it.whatsNewTranslation it.copy(whatsNewTranslation = current.copy(isShowingTranslation = !current.isShowingTranslation)) } } is DetailsAction.ShowLanguagePicker -> { _state.update { it.copy( isLanguagePickerVisible = true, languagePickerTarget = action.target, ) } } DetailsAction.DismissLanguagePicker -> { _state.update { it.copy(isLanguagePickerVisible = false, languagePickerTarget = null) } } DetailsAction.OpenWithExternalInstaller -> { val filePath = _state.value.pendingInstallFilePath if (filePath != null) { try { installer.openWithExternalInstaller(filePath) _state.value.primaryAsset?.let { asset -> _state.value.selectedRelease?.let { release -> appendLog( assetName = asset.name, size = asset.size, tag = release.tagName, result = LogResult.OpenedInExternalInstaller, ) } } } catch (t: Throwable) { logger.error("Failed to open with external installer: ${t.message}") _state.value = _state.value.copy(installError = t.message) } } _state.value = _state.value.copy( showExternalInstallerPrompt = false, pendingInstallFilePath = null, ) } DetailsAction.DismissExternalInstallerPrompt -> { _state.value = _state.value.copy( showExternalInstallerPrompt = false, pendingInstallFilePath = null, ) } DetailsAction.InstallWithExternalApp -> { currentDownloadJob?.cancel() val job = viewModelScope.launch { try { val primary = _state.value.primaryAsset val release = _state.value.selectedRelease if (primary != null && release != null) { currentAssetName = primary.name appendLog( assetName = primary.name, size = primary.size, tag = release.tagName, result = LogResult.DownloadStarted, ) _state.value = _state.value.copy( downloadError = null, installError = null, downloadProgressPercent = null, downloadStage = DownloadStage.DOWNLOADING, ) downloader .download(primary.downloadUrl, primary.name) .collect { p -> _state.value = _state.value.copy(downloadProgressPercent = p.percent) if (p.percent == 100) { _state.value = _state.value.copy(downloadStage = DownloadStage.VERIFYING) } } val filePath = downloader.getDownloadedFilePath(primary.name) ?: throw IllegalStateException("Downloaded file not found") appendLog( assetName = primary.name, size = primary.size, tag = release.tagName, result = LogResult.Downloaded, ) _state.value = _state.value.copy(downloadStage = DownloadStage.IDLE) currentAssetName = null installer.openWithExternalInstaller(filePath) appendLog( assetName = primary.name, size = primary.size, tag = release.tagName, result = LogResult.OpenedInExternalInstaller, ) } } catch (e: CancellationException) { logger.debug("Install with external app cancelled") _state.value = _state.value.copy(downloadStage = DownloadStage.IDLE) currentAssetName = null throw e } catch (t: Throwable) { logger.error("Failed to install with external app: ${t.message}") _state.value = _state.value.copy( downloadStage = DownloadStage.IDLE, installError = t.message, ) currentAssetName = null _state.value.primaryAsset?.let { asset -> _state.value.selectedRelease?.let { release -> appendLog( assetName = asset.name, size = asset.size, tag = release.tagName, result = Error(t.message), ) } } } } currentDownloadJob = job job.invokeOnCompletion { if (currentDownloadJob === job) { currentDownloadJob = null } } _state.update { it.copy(isInstallDropdownExpanded = false) } } DetailsAction.OnNavigateBackClick -> { // Handled in composable } is DetailsAction.OpenDeveloperProfile -> { // Handled in composable } is DetailsAction.OnMessage -> { // Handled in composable } is DetailsAction.SelectDownloadAsset -> { _state.update { state -> state.copy(primaryAsset = action.release) } } DetailsAction.ToggleReleaseAssetsPicker -> { _state.update { state -> state.copy(isReleaseSelectorVisible = !state.isReleaseSelectorVisible) } } } } private fun installAsset( downloadUrl: String, assetName: String, sizeBytes: Long, releaseTag: String, isUpdate: Boolean = false, ) { currentDownloadJob?.cancel() currentDownloadJob = viewModelScope.launch { try { val filePath: String = downloadAsset( assetName = assetName, sizeBytes = sizeBytes, releaseTag = releaseTag, isUpdate = isUpdate, downloadUrl = downloadUrl, ) ?: return@launch installAsset( isUpdate = isUpdate, filePath = filePath, assetName = assetName, downloadUrl = downloadUrl, sizeBytes = sizeBytes, releaseTag = releaseTag, ) } catch (e: kotlinx.coroutines.CancellationException) { throw e } catch (t: Throwable) { logger.error("Install failed: ${t.message}") t.printStackTrace() _state.value = _state.value.copy( downloadStage = DownloadStage.IDLE, installError = t.message, ) currentAssetName = null appendLog( assetName = assetName, size = sizeBytes, tag = releaseTag, result = Error(t.message), ) } } } private suspend fun installAsset( isUpdate: Boolean, filePath: String, assetName: String, downloadUrl: String, sizeBytes: Long, releaseTag: String, ) { _state.value = _state.value.copy(downloadStage = DownloadStage.INSTALLING) val ext = assetName.substringAfterLast('.', "").lowercase() val isApk = ext == "apk" if (isApk) { val apkInfo = installer.getApkInfoExtractor().extractPackageInfo(filePath) if (apkInfo == null) { logger.error("Failed to extract APK info for $assetName") _state.value = _state.value.copy( downloadStage = DownloadStage.IDLE, installError = "Failed to verify APK package info", ) currentAssetName = null appendLog( assetName = assetName, size = sizeBytes, tag = releaseTag, result = Error("Failed to extract APK info"), ) return } // Validate package name matches on updates val trackedApp = _state.value.installedApp if (isUpdate && trackedApp != null && apkInfo.packageName != trackedApp.packageName) { logger.error("Package name mismatch on update: APK=${apkInfo.packageName}, installed=${trackedApp.packageName}") _state.value = _state.value.copy( downloadStage = DownloadStage.IDLE, installError = getString( Res.string.update_package_mismatch, apkInfo.packageName, trackedApp.packageName, ), ) currentAssetName = null appendLog( assetName = assetName, size = sizeBytes, tag = releaseTag, result = Error("Package name mismatch"), ) return } val result = checkFingerprints( apkPackageInfo = apkInfo, ) result .onFailure { val existingApp = installedAppsRepository.getAppByPackage(apkInfo.packageName) _state.update { state -> state.copy( signingKeyWarning = SigningKeyWarning( packageName = apkInfo.packageName, expectedFingerprint = existingApp?.signingFingerprint ?: "", actualFingerprint = apkInfo.signingFingerprint ?: "", pendingDownloadUrl = downloadUrl, pendingAssetName = assetName, pendingSizeBytes = sizeBytes, pendingReleaseTag = releaseTag, pendingIsUpdate = isUpdate, pendingFilePath = filePath, ), ) } appendLog( assetName = assetName, size = sizeBytes, tag = releaseTag, result = Error("Signing key changed"), ) return } } installer.install(filePath, ext) // Launch attestation check asynchronously (non-blocking) launchAttestationCheck(filePath) if (platform == Platform.ANDROID) { saveInstalledAppToDatabase( assetName = assetName, assetUrl = downloadUrl, assetSize = sizeBytes, releaseTag = releaseTag, isUpdate = isUpdate, filePath = filePath, ) } else { viewModelScope.launch { _events.send(DetailsEvent.OnMessage(getString(Res.string.installer_saved_downloads))) } } _state.value = _state.value.copy(downloadStage = DownloadStage.IDLE) currentAssetName = null appendLog( assetName = assetName, size = sizeBytes, tag = releaseTag, result = if (isUpdate) { LogResult.Updated } else { LogResult.Installed }, ) } private suspend fun checkFingerprints(apkPackageInfo: ApkPackageInfo): Result { val existingApp = installedAppsRepository.getAppByPackage(apkPackageInfo.packageName) ?: return Result.success(Unit) if (existingApp.signingFingerprint == null) return Result.success(Unit) if (apkPackageInfo.signingFingerprint == null) return Result.success(Unit) return if (existingApp.signingFingerprint == apkPackageInfo.signingFingerprint) { Result.success(Unit) } else { Result.failure( IllegalStateException( "Signing key changed! Expected: ${existingApp.signingFingerprint}, got: ${apkPackageInfo.signingFingerprint}", ), ) } } private fun launchAttestationCheck(filePath: String) { val repo = _state.value.repository ?: return val owner = repo.owner.login val repoName = repo.name _state.update { it.copy(attestationStatus = AttestationStatus.CHECKING) } viewModelScope.launch { try { val digest = computeSha256(filePath) val verified = detailsRepository.checkAttestations(owner, repoName, digest) _state.update { it.copy( attestationStatus = if (verified) AttestationStatus.VERIFIED else AttestationStatus.UNVERIFIED, ) } } catch (e: Exception) { logger.debug("Attestation check error: ${e.message}") _state.update { it.copy(attestationStatus = AttestationStatus.UNVERIFIED) } } } } private fun computeSha256(filePath: String): String { val digest = MessageDigest.getInstance("SHA-256") val buffer = ByteArray(8192) FileInputStream(File(filePath)).use { fis -> var bytesRead: Int while (fis.read(buffer).also { bytesRead = it } != -1) { digest.update(buffer, 0, bytesRead) } } return digest.digest().joinToString("") { "%02x".format(it) } } private suspend fun downloadAsset( assetName: String, sizeBytes: Long, releaseTag: String, isUpdate: Boolean, downloadUrl: String, ): String? { currentAssetName = assetName appendLog( assetName = assetName, size = sizeBytes, tag = releaseTag, result = if (isUpdate) { LogResult.UpdateStarted } else { LogResult.DownloadStarted }, ) _state.value = _state.value.copy( downloadError = null, installError = null, downloadProgressPercent = null, attestationStatus = AttestationStatus.UNCHECKED, ) val existingPath = downloader.getDownloadedFilePath(assetName) val filePath: String val existingFile = existingPath?.let { File(it) } if (existingFile != null && existingFile.exists() && existingFile.length() == sizeBytes) { logger.debug("Reusing already downloaded file: $assetName") filePath = existingPath _state.value = _state.value.copy( downloadProgressPercent = 100, downloadedBytes = sizeBytes, totalBytes = sizeBytes, downloadStage = DownloadStage.VERIFYING, ) } else { _state.value = _state.value.copy( downloadStage = DownloadStage.DOWNLOADING, downloadedBytes = 0L, totalBytes = sizeBytes, ) downloader.download(downloadUrl, assetName).collect { p -> _state.value = _state.value.copy( downloadProgressPercent = p.percent, downloadedBytes = p.bytesDownloaded, totalBytes = p.totalBytes ?: sizeBytes, ) if (p.percent == 100) { _state.value = _state.value.copy(downloadStage = DownloadStage.VERIFYING) } } filePath = downloader.getDownloadedFilePath(assetName) ?: throw IllegalStateException("Downloaded file not found") cachedDownloadAssetName = assetName } appendLog( assetName = assetName, size = sizeBytes, tag = releaseTag, result = LogResult.Downloaded, ) val ext = assetName.substringAfterLast('.', "").lowercase() if (!installer.isSupported(ext)) { throw IllegalStateException("Asset type .$ext not supported") } try { installer.ensurePermissionsOrThrow(extOrMime = ext) } catch (e: IllegalStateException) { logger.warn("Install permission blocked: ${e.message}") _state.value = _state.value.copy( downloadStage = DownloadStage.IDLE, showExternalInstallerPrompt = true, pendingInstallFilePath = filePath, ) currentAssetName = null appendLog( assetName = assetName, size = sizeBytes, tag = releaseTag, result = LogResult.PermissionBlocked, ) return null } return filePath } @OptIn(ExperimentalTime::class) private suspend fun saveInstalledAppToDatabase( assetName: String, assetUrl: String, assetSize: Long, releaseTag: String, isUpdate: Boolean, filePath: String, ) { try { val repo = _state.value.repository ?: return val apkInfo: ApkPackageInfo = if (platform == Platform.ANDROID && assetName.lowercase().endsWith(".apk")) { val apkInfo = installer.getApkInfoExtractor().extractPackageInfo(filePath) if (apkInfo != null) { ApkPackageInfo( packageName = apkInfo.packageName, appName = apkInfo.appName, versionName = apkInfo.versionName, versionCode = apkInfo.versionCode, signingFingerprint = apkInfo.signingFingerprint, ) } else { logger.error("Failed to extract APK info for $assetName") return } } else { return } if (isUpdate) { installedAppsRepository.updateAppVersion( packageName = apkInfo.packageName, newTag = releaseTag, newAssetName = assetName, newAssetUrl = assetUrl, newVersionName = apkInfo.versionName, newVersionCode = apkInfo.versionCode, signingFingerprint = apkInfo.signingFingerprint, ) } else { val installedApp = InstalledApp( packageName = apkInfo.packageName, repoId = repo.id, repoName = repo.name, repoOwner = repo.owner.login, repoOwnerAvatarUrl = repo.owner.avatarUrl, repoDescription = repo.description, primaryLanguage = repo.language, repoUrl = repo.htmlUrl, installedVersion = releaseTag, installedAssetName = assetName, installedAssetUrl = assetUrl, latestVersion = releaseTag, latestAssetName = assetName, latestAssetUrl = assetUrl, latestAssetSize = assetSize, appName = apkInfo.appName, installSource = InstallSource.THIS_APP, installedAt = System.now().toEpochMilliseconds(), lastCheckedAt = System.now().toEpochMilliseconds(), lastUpdatedAt = System.now().toEpochMilliseconds(), isUpdateAvailable = false, updateCheckEnabled = true, releaseNotes = "", systemArchitecture = installer.detectSystemArchitecture().name, fileExtension = assetName.substringAfterLast('.', ""), isPendingInstall = true, installedVersionName = apkInfo.versionName, installedVersionCode = apkInfo.versionCode, latestVersionName = apkInfo.versionName, latestVersionCode = apkInfo.versionCode, signingFingerprint = apkInfo.signingFingerprint, ) installedAppsRepository.saveInstalledApp(installedApp) } if (_state.value.isFavourite) { favouritesRepository.updateFavoriteInstallStatus( repoId = repo.id, installed = true, packageName = apkInfo.packageName, ) } delay(1000) val updatedApp = installedAppsRepository.getAppByPackage(apkInfo.packageName) _state.value = _state.value.copy(installedApp = updatedApp) logger.debug("Successfully saved and reloaded app: ${updatedApp?.packageName}") } catch (t: Throwable) { logger.error("Failed to save installed app to database: ${t.message}") t.printStackTrace() } } private fun downloadAsset( downloadUrl: String, assetName: String, sizeBytes: Long, releaseTag: String, ) { currentDownloadJob?.cancel() currentDownloadJob = viewModelScope.launch { try { currentAssetName = assetName appendLog( assetName = assetName, size = sizeBytes, tag = releaseTag, result = LogResult.DownloadStarted, ) _state.value = _state.value.copy( isDownloading = true, downloadError = null, installError = null, downloadProgressPercent = null, ) downloader.download(downloadUrl, assetName).collect { p -> _state.value = _state.value.copy(downloadProgressPercent = p.percent) } _state.value = _state.value.copy(isDownloading = false) currentAssetName = null appendLog( assetName = assetName, size = sizeBytes, tag = releaseTag, result = LogResult.Downloaded, ) } catch (t: Throwable) { _state.value = _state.value.copy( isDownloading = false, downloadError = t.message, ) currentAssetName = null appendLog( assetName = assetName, size = sizeBytes, tag = releaseTag, result = LogResult.Error(t.message), ) } } } @OptIn(ExperimentalTime::class) private fun appendLog( assetName: String, size: Long, tag: String, result: LogResult, ) { val now = System .now() .toLocalDateTime(TimeZone.currentSystemDefault()) .format( LocalDateTime.Format { year() char('-') monthNumber() char('-') day() char(' ') hour() char(':') minute() char(':') second() }, ) val newItem = InstallLogItem( timeIso = now, assetName = assetName, assetSizeBytes = size, releaseTag = tag, result = result, ) _state.value = _state.value.copy( installLogs = listOf(newItem) + _state.value.installLogs, ) } override fun onCleared() { super.onCleared() currentDownloadJob?.cancel() val assetsToClean = listOfNotNull(currentAssetName, cachedDownloadAssetName).distinct() if (assetsToClean.isNotEmpty()) { viewModelScope.launch(NonCancellable) { for (asset in assetsToClean) { try { downloader.cancelDownload(asset) logger.debug("Cleaned up download on screen leave: $asset") } catch (t: Throwable) { logger.error("Failed to clean download on leave: ${t.message}") } } } } } private fun translateContent( text: String, targetLanguageCode: String, updateState: (TranslationState) -> Unit, getCurrentState: () -> TranslationState, ): Job = viewModelScope.launch { try { updateState( getCurrentState().copy( isTranslating = true, error = null, targetLanguageCode = targetLanguageCode, ), ) val result = translationRepository.translate( text = text, targetLanguage = targetLanguageCode, ) val langDisplayName = SupportedLanguages.all .find { it.code == targetLanguageCode } ?.displayName ?: targetLanguageCode updateState( TranslationState( isTranslating = false, translatedText = result.translatedText, isShowingTranslation = true, targetLanguageCode = targetLanguageCode, targetLanguageDisplayName = langDisplayName, detectedSourceLanguage = result.detectedSourceLanguage, ), ) } catch (e: CancellationException) { throw e } catch (e: Exception) { logger.error("Translation failed: ${e.message}") updateState( getCurrentState().copy( isTranslating = false, error = e.message, ), ) _events.send( DetailsEvent.OnMessage(getString(Res.string.translation_failed)), ) } } private fun normalizeVersion(version: String?): String = version?.removePrefix("v")?.removePrefix("V")?.trim() ?: "" /** * Returns true if [candidate] is strictly older than [current]. * Uses list-index order as primary heuristic (releases are newest-first), * and falls back to semantic version comparison when list lookup fails. */ private fun isDowngradeVersion( candidate: String, current: String, allReleases: List, ): Boolean { val normalizedCandidate = normalizeVersion(candidate) val normalizedCurrent = normalizeVersion(current) if (normalizedCandidate == normalizedCurrent) return false val candidateIndex = allReleases.indexOfFirst { normalizeVersion(it.tagName) == normalizedCandidate } val currentIndex = allReleases.indexOfFirst { normalizeVersion(it.tagName) == normalizedCurrent } if (candidateIndex != -1 && currentIndex != -1) { return candidateIndex > currentIndex } return compareSemanticVersions(normalizedCandidate, normalizedCurrent) < 0 } /** * Compares two semantic version strings. Returns positive if a > b, negative if a < b, 0 if equal. */ private fun compareSemanticVersions( a: String, b: String, ): Int { val aCore = a.split("-", limit = 2) val bCore = b.split("-", limit = 2) val aParts = aCore[0].split(".") val bParts = bCore[0].split(".") val maxLen = maxOf(aParts.size, bParts.size) for (i in 0 until maxLen) { val aPart = aParts.getOrNull(i)?.filter { it.isDigit() }?.toLongOrNull() ?: 0L val bPart = bParts.getOrNull(i)?.filter { it.isDigit() }?.toLongOrNull() ?: 0L if (aPart != bPart) return aPart.compareTo(bPart) } val aHasPre = aCore.size > 1 val bHasPre = bCore.size > 1 if (aHasPre != bHasPre) return if (aHasPre) -1 else 1 return 0 } private companion object { const val OBTAINIUM_REPO_ID: Long = 523534328 const val APP_MANAGER_REPO_ID: Long = 268006778 } } ================================================ FILE: feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/AppHeader.kt ================================================ package zed.rainxch.details.presentation.components import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row 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.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.filled.Update import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf 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.StrokeCap import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.skydoves.landscapist.coil3.CoilImage import org.jetbrains.compose.resources.stringResource import zed.rainxch.core.domain.model.DiscoveryPlatform import zed.rainxch.core.domain.model.GithubRelease import zed.rainxch.core.domain.model.GithubRepoSummary import zed.rainxch.core.domain.model.GithubUserProfile import zed.rainxch.core.domain.model.InstalledApp import zed.rainxch.core.presentation.components.ForkBadge import zed.rainxch.core.presentation.components.PlatformChip import zed.rainxch.core.presentation.utils.formatReleasedAt import zed.rainxch.details.presentation.model.DownloadStage import zed.rainxch.githubstore.core.presentation.res.Res import zed.rainxch.githubstore.core.presentation.res.by_author import zed.rainxch.githubstore.core.presentation.res.installed import zed.rainxch.githubstore.core.presentation.res.installed_version import zed.rainxch.githubstore.core.presentation.res.no_description import zed.rainxch.githubstore.core.presentation.res.pending_install import zed.rainxch.githubstore.core.presentation.res.update_available @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalLayoutApi::class) @Composable fun AppHeader( author: GithubUserProfile?, repository: GithubRepoSummary, release: GithubRelease?, installedApp: InstalledApp?, modifier: Modifier = Modifier, downloadStage: DownloadStage = DownloadStage.IDLE, downloadProgress: Int? = null, ) { val animatedProgress by animateFloatAsState( targetValue = (downloadProgress ?: 0) / 100f, animationSpec = tween(durationMillis = 500), label = "avatar_progress_animation", ) val supportedPlatforms by remember(release?.assets) { derivedStateOf { derivePlatformsFromAssets(release) } } Column( modifier = modifier.fillMaxWidth(), ) { Row( verticalAlignment = Alignment.Top, ) { Box( contentAlignment = Alignment.Center, modifier = Modifier.size(100.dp), ) { CoilImage( imageModel = { author?.avatarUrl }, modifier = Modifier .size(100.dp) .clip(CircleShape) .border( width = 1.dp, color = MaterialTheme.colorScheme.outlineVariant, shape = CircleShape, ), loading = { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { CircularWavyProgressIndicator() } }, ) if (downloadStage != DownloadStage.IDLE) { Box( contentAlignment = Alignment.Center, modifier = Modifier.size(100.dp), ) { when (downloadStage) { DownloadStage.DOWNLOADING -> { CircularProgressIndicator( progress = { 1f }, modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f), strokeWidth = 4.dp, ) CircularProgressIndicator( progress = { animatedProgress }, modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.primary, strokeWidth = 4.dp, strokeCap = StrokeCap.Round, ) } DownloadStage.VERIFYING, DownloadStage.INSTALLING -> { CircularProgressIndicator( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.primary, strokeWidth = 4.dp, strokeCap = StrokeCap.Round, ) } else -> {} } } } } Spacer(Modifier.width(16.dp)) Column( modifier = Modifier.weight(1f), ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Text( text = repository.name, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.weight(1f, fill = false), ) if (repository.isFork) { ForkBadge() } } author?.login?.let { author -> Text( text = stringResource(Res.string.by_author, author), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary, ) } Spacer(Modifier.height(8.dp)) if (installedApp != null) { when { installedApp.isPendingInstall -> { PendingInstallBadge() } else -> { InstallStatusBadge( isUpdateAvailable = installedApp.isUpdateAvailable, ) } } } Spacer(Modifier.height(8.dp)) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { release?.tagName?.let { Text( text = it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary, ) } if (installedApp != null && installedApp.installedVersion != release?.tagName) { Text( text = stringResource( Res.string.installed_version, installedApp.installedVersion, ), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } release?.publishedAt?.let { publishedAt -> Spacer(Modifier.height(4.dp)) Text( text = formatReleasedAt(publishedAt), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline, ) } } } if (supportedPlatforms.isNotEmpty()) { Spacer(Modifier.height(12.dp)) FlowRow( horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp), ) { supportedPlatforms.forEach { platform -> PlatformChip(platform = platform) } } } Spacer(Modifier.height(16.dp)) Text( text = repository.description ?: stringResource(Res.string.no_description), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } private fun derivePlatformsFromAssets(release: GithubRelease?): List { if (release == null) return emptyList() val names = release.assets.map { it.name.lowercase() } return buildList { if (names.any { it.endsWith(".apk") }) add(DiscoveryPlatform.Android) if (names.any { it.endsWith(".exe") || it.endsWith(".msi") }) add(DiscoveryPlatform.Windows) if (names.any { it.endsWith(".dmg") || it.endsWith(".pkg") }) add(DiscoveryPlatform.Macos) if (names.any { it.endsWith(".appimage") || it.endsWith(".deb") || it.endsWith(".rpm") }) { add( DiscoveryPlatform.Linux, ) } } } @Composable fun InstallStatusBadge( isUpdateAvailable: Boolean, modifier: Modifier = Modifier, ) { val backgroundColor = if (isUpdateAvailable) { MaterialTheme.colorScheme.tertiaryContainer } else { MaterialTheme.colorScheme.primaryContainer } val textColor = if (isUpdateAvailable) { MaterialTheme.colorScheme.onTertiaryContainer } else { MaterialTheme.colorScheme.onPrimaryContainer } val icon = if (isUpdateAvailable) { Icons.Default.Update } else { Icons.Default.CheckCircle } val text = if (isUpdateAvailable) { stringResource(Res.string.update_available) } else { stringResource(Res.string.installed) } Surface( modifier = modifier, shape = RoundedCornerShape(12.dp), color = backgroundColor, ) { Row( modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { Icon( imageVector = icon, contentDescription = null, modifier = Modifier.size(14.dp), tint = textColor, ) Text( text = text, style = MaterialTheme.typography.labelSmall, color = textColor, fontWeight = FontWeight.SemiBold, ) } } } @Composable fun PendingInstallBadge(modifier: Modifier = Modifier) { Surface( modifier = modifier, shape = RoundedCornerShape(12.dp), color = MaterialTheme.colorScheme.secondaryContainer, ) { Row( modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { Icon( imageVector = Icons.Default.Schedule, contentDescription = null, modifier = Modifier.size(14.dp), tint = MaterialTheme.colorScheme.onSecondaryContainer, ) Text( text = stringResource(Res.string.pending_install), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSecondaryContainer, fontWeight = FontWeight.SemiBold, ) } } } ================================================ FILE: feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/LanguagePicker.kt ================================================ package zed.rainxch.details.presentation.components import androidx.compose.foundation.background import androidx.compose.foundation.clickable 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.navigationBarsPadding 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.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Smartphone import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState 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.text.font.FontWeight import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import zed.rainxch.details.domain.model.SupportedLanguage import zed.rainxch.details.presentation.model.SupportedLanguages import zed.rainxch.githubstore.core.presentation.res.* @OptIn(ExperimentalMaterial3Api::class) @Composable fun LanguagePicker( isVisible: Boolean, selectedLanguageCode: String?, deviceLanguageCode: String, onLanguageSelected: (SupportedLanguage) -> Unit, onDismiss: () -> Unit, ) { if (!isVisible) return val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) var searchQuery by remember { mutableStateOf("") } val deviceLanguage = remember(deviceLanguageCode) { SupportedLanguages.all.find { it.code == deviceLanguageCode } } val filteredLanguages = remember(searchQuery) { val all = SupportedLanguages.all if (searchQuery.isBlank()) { all } else { all.filter { it.displayName.contains(searchQuery, ignoreCase = true) || it.code.contains(searchQuery, ignoreCase = true) } } } ModalBottomSheet( onDismissRequest = onDismiss, sheetState = sheetState, ) { Column( modifier = Modifier .fillMaxWidth() .navigationBarsPadding(), ) { Text( text = stringResource(Res.string.translate_to), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), ) OutlinedTextField( value = searchQuery, onValueChange = { searchQuery = it }, placeholder = { Text(stringResource(Res.string.search_language)) }, leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, singleLine = true, shape = RoundedCornerShape(12.dp), modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp), ) // Device language shortcut — only shown when not searching if (searchQuery.isBlank() && deviceLanguage != null) { Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 4.dp) .clip(RoundedCornerShape(12.dp)) .background(MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f)) .clickable { onLanguageSelected(deviceLanguage) } .padding(horizontal = 12.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( imageVector = Icons.Default.Smartphone, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(18.dp), ) Spacer(Modifier.width(10.dp)) Column(modifier = Modifier.weight(1f)) { Text( text = deviceLanguage.displayName, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.primary, ) Text( text = stringResource(Res.string.select_language), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f), ) } if (deviceLanguage.code == selectedLanguageCode) { Icon( imageVector = Icons.Default.CheckCircle, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp), ) } } Spacer(Modifier.height(4.dp)) } HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) LazyColumn( modifier = Modifier.fillMaxWidth(), contentPadding = PaddingValues(vertical = 8.dp), ) { items( items = filteredLanguages, key = { it.code }, ) { language -> LanguageListItem( language = language, isSelected = language.code == selectedLanguageCode, onClick = { onLanguageSelected(language) }, ) } } } } } @Composable private fun LanguageListItem( language: SupportedLanguage, isSelected: Boolean, onClick: () -> Unit, ) { Row( modifier = Modifier .fillMaxWidth() .clickable(onClick = onClick) .padding(horizontal = 16.dp, vertical = 12.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f)) { Text( text = language.displayName, style = MaterialTheme.typography.titleSmall, fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, color = if (isSelected) { MaterialTheme.colorScheme.primary } else { MaterialTheme.colorScheme.onSurface }, ) Text( text = language.code, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline, ) } if (isSelected) { Spacer(Modifier.width(8.dp)) Icon( imageVector = Icons.Default.CheckCircle, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp), ) } } } ================================================ FILE: feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/ReleaseAssetsPicker.kt ================================================ package zed.rainxch.details.presentation.components import androidx.compose.foundation.clickable 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.heightIn import androidx.compose.foundation.layout.navigationBarsPadding 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.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.UnfoldMore import androidx.compose.material.icons.outlined.Info import androidx.compose.material3.AlertDialogDefaults import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.contentColorFor import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import org.jetbrains.compose.resources.stringResource import zed.rainxch.core.domain.model.GithubAsset import zed.rainxch.core.domain.model.GithubUser import zed.rainxch.details.presentation.DetailsAction import zed.rainxch.githubstore.core.presentation.res.* import zed.rainxch.githubstore.core.presentation.res.Res @OptIn( ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class, ) @Composable fun ReleaseAssetsPicker( onAction: (DetailsAction) -> Unit, assetsList: List, modifier: Modifier = Modifier, selectedAsset: GithubAsset? = null, isPickerVisible: Boolean = false, ) { val isPickerEnabled by remember(assetsList) { derivedStateOf { assetsList.isNotEmpty() } } ReleaseAssetsItemsPicker( showPicker = isPickerVisible, assetsList = assetsList, selectedAsset = selectedAsset, onDismiss = { onAction(DetailsAction.ToggleReleaseAssetsPicker) }, onSelect = { onAction(DetailsAction.SelectDownloadAsset(it)) }, ) Column( modifier = modifier.wrapContentHeight(), verticalArrangement = Arrangement.spacedBy(4.dp), ) { Text( text = stringResource(Res.string.assets_title), style = MaterialTheme.typography.labelLargeEmphasized, color = MaterialTheme.colorScheme.tertiary, modifier = Modifier.padding(horizontal = 4.dp), ) OutlinedCard( onClick = { onAction(DetailsAction.ToggleReleaseAssetsPicker) }, enabled = isPickerEnabled, modifier = Modifier.fillMaxWidth(), ) { Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 12.dp) .heightIn(min = 36.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { Text( text = selectedAsset?.name ?: stringResource(Res.string.no_assets_selected), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, overflow = TextOverflow.Ellipsis, maxLines = 1, modifier = Modifier.weight(1f), ) Icon( imageVector = Icons.Default.UnfoldMore, contentDescription = stringResource(Res.string.select_version), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } } @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ReleaseAssetsItemsPicker( assetsList: List, selectedAsset: GithubAsset?, showPicker: Boolean, onDismiss: () -> Unit, onSelect: (GithubAsset) -> Unit, modifier: Modifier = Modifier, ) { if (!showPicker) return val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) var showInfoDialog by rememberSaveable { mutableStateOf(false) } ReleaseAssetsAboutDialog( showDialog = showInfoDialog, onDismiss = { showInfoDialog = false }, ) ModalBottomSheet( onDismissRequest = onDismiss, sheetState = sheetState, modifier = modifier, ) { Column( modifier = Modifier .fillMaxWidth() .navigationBarsPadding(), ) { Row(verticalAlignment = Alignment.CenterVertically) { Text( text = stringResource(Res.string.assets_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, modifier = Modifier .padding(horizontal = 16.dp, vertical = 8.dp) .weight(1f), ) IconButton(onClick = { showInfoDialog = true }) { Icon(imageVector = Icons.Outlined.Info, contentDescription = stringResource(Res.string.icon_content_description_info)) } } HorizontalDivider() LazyColumn( modifier = Modifier.fillMaxWidth(), contentPadding = PaddingValues(vertical = 8.dp), ) { if (assetsList.isNotEmpty()) { items(items = assetsList, key = { it.id }) { asset -> ReleaseAssetItem( asset = asset, isSelected = asset.id == selectedAsset?.id, onClick = { onSelect(asset) }, modifier = Modifier.fillMaxWidth(), ) } } else { item { Text( text = stringResource(Res.string.no_assets_in_list), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth().padding(16.dp), ) } } } } } } @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ReleaseAssetsAboutDialog( showDialog: Boolean, onDismiss: () -> Unit, modifier: Modifier = Modifier, properties: DialogProperties = DialogProperties(), containerColor: Color = AlertDialogDefaults.containerColor, shape: Shape = AlertDialogDefaults.shape, ) { if (!showDialog) return BasicAlertDialog(onDismissRequest = onDismiss, modifier = modifier, properties = properties) { Surface( color = containerColor, contentColor = contentColorFor(containerColor), shape = shape, ) { Column( modifier = Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { Text( text = stringResource(Res.string.multiple_assets_info_dialog_title), style = MaterialTheme.typography.headlineSmall, color = AlertDialogDefaults.titleContentColor, ) Text( text = stringResource(Res.string.multiple_assets_info_dialog_text), style = MaterialTheme.typography.bodyMedium, color = AlertDialogDefaults.textContentColor, ) } } } } @Composable private fun ReleaseAssetItem( asset: GithubAsset, isSelected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, ) { Row( modifier = modifier .clickable( onClickLabel = stringResource(Res.string.assets_selection_label), onClick = onClick, ).padding(horizontal = 16.dp, vertical = 12.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f)) { Text( text = asset.name, style = MaterialTheme.typography.titleSmall, fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, overflow = TextOverflow.Ellipsis, maxLines = 2, color = if (isSelected) { MaterialTheme.colorScheme.primary } else { MaterialTheme.colorScheme.onSurface }, ) Text( text = formatFileSize(asset.size), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } if (isSelected) { Spacer(Modifier.width(8.dp)) Icon( imageVector = Icons.Default.CheckCircle, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp), ) } } } private fun formatFileSize(bytes: Long): String = when { bytes >= 1_073_741_824 -> "%.1f GB".format(bytes / 1_073_741_824.0) bytes >= 1_048_576 -> "%.1f MB".format(bytes / 1_048_576.0) bytes >= 1_024 -> "%.1f KB".format(bytes / 1_024.0) else -> "$bytes B" } @Preview @Composable private fun ReleaseAssetsPickerItemPreview() { ReleaseAssetItem( asset = GithubAsset( id = -1, name = "Random", contentType = "", size = 20 * 1024, downloadUrl = "", uploader = GithubUser(id = -1, login = "", avatarUrl = "", htmlUrl = ""), ), onClick = {}, isSelected = false, ) } ================================================ FILE: feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/SmartInstallButton.kt ================================================ package zed.rainxch.details.presentation.components import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.background 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.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize 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.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.Update import androidx.compose.material.icons.filled.VerifiedUser import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.github.fletchmckee.liquid.liquefiable import io.github.fletchmckee.liquid.rememberLiquidState import org.jetbrains.compose.resources.stringResource import zed.rainxch.core.domain.model.GithubAsset import zed.rainxch.core.domain.model.GithubUser import zed.rainxch.details.presentation.DetailsAction import zed.rainxch.details.presentation.DetailsState import zed.rainxch.details.presentation.model.AttestationStatus import zed.rainxch.details.presentation.model.DownloadStage import zed.rainxch.details.presentation.utils.LocalTopbarLiquidState import zed.rainxch.details.presentation.utils.extractArchitectureFromName import zed.rainxch.details.presentation.utils.isExactArchitectureMatch import zed.rainxch.githubstore.core.presentation.res.Res import zed.rainxch.githubstore.core.presentation.res.architecture_compatible import zed.rainxch.githubstore.core.presentation.res.cancel_download import zed.rainxch.githubstore.core.presentation.res.checking_attestation import zed.rainxch.githubstore.core.presentation.res.downloading import zed.rainxch.githubstore.core.presentation.res.install_latest import zed.rainxch.githubstore.core.presentation.res.install_version import zed.rainxch.githubstore.core.presentation.res.installing import zed.rainxch.githubstore.core.presentation.res.not_available import zed.rainxch.githubstore.core.presentation.res.open_app import zed.rainxch.githubstore.core.presentation.res.show_install_options import zed.rainxch.githubstore.core.presentation.res.uninstall import zed.rainxch.githubstore.core.presentation.res.update_to_version import zed.rainxch.githubstore.core.presentation.res.updating import zed.rainxch.githubstore.core.presentation.res.verified_build import zed.rainxch.githubstore.core.presentation.res.verifying @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun SmartInstallButton( isDownloading: Boolean, isInstalling: Boolean, isLiquidGlassEnabled: Boolean, progress: Int?, primaryAsset: GithubAsset?, onAction: (DetailsAction) -> Unit, modifier: Modifier = Modifier, state: DetailsState, ) { val liquidState = LocalTopbarLiquidState.current val installedApp = state.installedApp val isInstalled = installedApp != null && !installedApp.isPendingInstall val isUpdateAvailable = installedApp?.isUpdateAvailable == true && !installedApp.isPendingInstall val isSameVersionInstalled = isInstalled && installedApp != null && normalizeVersion(installedApp.installedVersion) == normalizeVersion( state.selectedRelease?.tagName ?: "", ) val enabled = remember(primaryAsset, isDownloading, isInstalling) { primaryAsset != null && !isDownloading && !isInstalling } val isActiveDownload = state.isDownloading || state.downloadStage != DownloadStage.IDLE // When same version is installed, show Open button if (isSameVersionInstalled && !isActiveDownload) { Column(modifier = modifier) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { // Uninstall button ElevatedCard( onClick = { onAction(DetailsAction.OnRequestUninstall) }, modifier = Modifier .weight(1f) .height(52.dp) .then( if (isLiquidGlassEnabled) { Modifier.liquefiable(liquidState) } else { Modifier }, ), colors = CardDefaults.elevatedCardColors( containerColor = MaterialTheme.colorScheme.errorContainer, ), shape = RoundedCornerShape( topStart = 24.dp, bottomStart = 24.dp, topEnd = 6.dp, bottomEnd = 6.dp, ), ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), ) { Icon( imageVector = Icons.Default.Delete, contentDescription = null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onErrorContainer, ) Text( text = stringResource(Res.string.uninstall), color = MaterialTheme.colorScheme.onErrorContainer, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium, ) } } } // Open button ElevatedCard( modifier = Modifier .weight(1f) .height(52.dp) .then( if (isLiquidGlassEnabled) { Modifier.liquefiable(liquidState) } else { Modifier }, ), colors = CardDefaults.elevatedCardColors( containerColor = MaterialTheme.colorScheme.primary, ), shape = RoundedCornerShape( topStart = 6.dp, bottomStart = 6.dp, topEnd = 24.dp, bottomEnd = 24.dp, ), onClick = { onAction(DetailsAction.OpenApp) }, ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), ) { Icon( imageVector = Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onPrimary, ) Text( text = stringResource(Res.string.open_app), color = MaterialTheme.colorScheme.onPrimary, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium, ) } } } } AttestationBadge(attestationStatus = state.attestationStatus) } return } // Regular install/update button for all other cases val buttonColor = when { !enabled && !isActiveDownload -> MaterialTheme.colorScheme.surfaceContainer isUpdateAvailable -> MaterialTheme.colorScheme.tertiary isInstalled -> MaterialTheme.colorScheme.secondary else -> MaterialTheme.colorScheme.primary } val buttonText = when { !enabled && primaryAsset == null -> { stringResource(Res.string.not_available) } isUpdateAvailable -> { stringResource( Res.string.update_to_version, installedApp.latestVersion.toString(), ) } isInstalled && installedApp.installedVersion != state.selectedRelease?.tagName -> { stringResource( Res.string.install_version, state.selectedRelease?.tagName ?: "", ) } else -> { stringResource(Res.string.install_latest) } } Row( modifier = modifier, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { ElevatedCard( modifier = Modifier .weight(1f) .height(52.dp) .background( color = buttonColor, shape = CircleShape, ).clickable( enabled = enabled, onClick = { if (!state.isDownloading && state.downloadStage == DownloadStage.IDLE) { if (isUpdateAvailable) { onAction(DetailsAction.UpdateApp) } else { onAction(DetailsAction.InstallPrimary) } } }, ).then( if (isLiquidGlassEnabled) { Modifier.liquefiable(liquidState) } else { Modifier }, ), colors = CardDefaults.elevatedCardColors( containerColor = buttonColor, ), shape = if (state.isObtainiumEnabled || isActiveDownload) { RoundedCornerShape( topStart = 24.dp, bottomStart = 24.dp, topEnd = 6.dp, bottomEnd = 6.dp, ) } else { CircleShape }, ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { if (isActiveDownload) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { when (state.downloadStage) { DownloadStage.DOWNLOADING -> { Text( text = if (isUpdateAvailable) { stringResource(Res.string.updating) } else { stringResource( Res.string.downloading, ) }, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onPrimary, fontWeight = FontWeight.Bold, ) val progressText = if (state.totalBytes != null && state.totalBytes > 0) { "${formatFileSize(state.downloadedBytes)} / ${ formatFileSize( state.totalBytes, ) }" } else { "${progress ?: 0}%" } Text( text = progressText, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f), ) } DownloadStage.VERIFYING -> { Text( text = stringResource(Res.string.verifying), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onPrimary, fontWeight = FontWeight.Bold, ) } DownloadStage.INSTALLING -> { Text( text = if (isUpdateAvailable) { stringResource(Res.string.updating) } else { stringResource( Res.string.installing, ) }, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onPrimary, fontWeight = FontWeight.Bold, ) } DownloadStage.IDLE -> {} } } } else { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), ) { if (isUpdateAvailable) { Icon( imageVector = Icons.Default.Update, contentDescription = null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onTertiary, ) } else if (isInstalled) { Icon( imageVector = Icons.Default.CheckCircle, contentDescription = null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSecondary, ) } Text( text = buttonText, color = if (enabled) { when { isUpdateAvailable -> MaterialTheme.colorScheme.onTertiary isInstalled -> MaterialTheme.colorScheme.onSecondary else -> MaterialTheme.colorScheme.onPrimary } } else { MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) }, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium, ) } if (primaryAsset != null) { val assetArch = extractArchitectureFromName(primaryAsset.name) val systemArch = state.systemArchitecture val sizeText = formatFileSize(primaryAsset.size) val archLabel = assetArch ?: systemArch.name.lowercase() val subtitle = "$archLabel \u2022 $sizeText" Spacer(modifier = Modifier.height(2.dp)) Row( horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { Text( text = subtitle, color = if (enabled) { when { isUpdateAvailable -> { MaterialTheme.colorScheme.onTertiary.copy( alpha = 0.8f, ) } isInstalled -> { MaterialTheme.colorScheme.onSecondary.copy( alpha = 0.8f, ) } else -> { MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f) } } } else { MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) }, style = MaterialTheme.typography.bodySmall, ) if (assetArch != null && isExactArchitectureMatch( assetName = primaryAsset.name.lowercase(), systemArch = systemArch, ) ) { Spacer(modifier = Modifier.width(4.dp)) Icon( imageVector = Icons.Default.CheckCircle, contentDescription = stringResource(Res.string.architecture_compatible), tint = if (enabled) { when { isUpdateAvailable -> { MaterialTheme.colorScheme.onTertiary.copy( alpha = 0.8f, ) } isInstalled -> { MaterialTheme.colorScheme.onSecondary.copy( alpha = 0.8f, ) } else -> { MaterialTheme.colorScheme.onPrimary.copy( alpha = 0.8f, ) } } } else { MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) }, modifier = Modifier.size(14.dp), ) } } } } } } } if (isActiveDownload) { IconButton( onClick = { onAction(DetailsAction.CancelCurrentDownload) }, colors = IconButtonDefaults.iconButtonColors( containerColor = MaterialTheme.colorScheme.errorContainer, ), modifier = Modifier.size(52.dp), shape = RoundedCornerShape( topStart = 6.dp, bottomStart = 6.dp, topEnd = 24.dp, bottomEnd = 24.dp, ), ) { Icon( imageVector = Icons.Default.Close, contentDescription = stringResource(Res.string.cancel_download), modifier = Modifier.size(24.dp), tint = MaterialTheme.colorScheme.onErrorContainer, ) } } else if (state.isObtainiumEnabled) { IconButton( onClick = { onAction(DetailsAction.OnToggleInstallDropdown) }, colors = IconButtonDefaults.iconButtonColors( containerColor = if (enabled) { buttonColor } else { MaterialTheme.colorScheme.surfaceContainer }, ), modifier = Modifier.size(52.dp), shape = RoundedCornerShape( topStart = 6.dp, bottomStart = 6.dp, topEnd = 24.dp, bottomEnd = 24.dp, ), ) { Icon( imageVector = Icons.Default.KeyboardArrowDown, contentDescription = stringResource(Res.string.show_install_options), modifier = Modifier.size(24.dp), tint = if (enabled) { when { isUpdateAvailable -> MaterialTheme.colorScheme.onTertiary isInstalled -> MaterialTheme.colorScheme.onSecondary else -> MaterialTheme.colorScheme.onPrimary } } else { MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) }, ) } } } } @Composable private fun AttestationBadge(attestationStatus: AttestationStatus) { AnimatedVisibility( visible = attestationStatus == AttestationStatus.VERIFIED || attestationStatus == AttestationStatus.CHECKING, enter = fadeIn(), exit = fadeOut(), ) { Row( modifier = Modifier.padding(top = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, ) { when (attestationStatus) { AttestationStatus.CHECKING -> { CircularProgressIndicator( modifier = Modifier.size(14.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(modifier = Modifier.width(6.dp)) Text( text = stringResource(Res.string.checking_attestation), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } AttestationStatus.VERIFIED -> { Icon( imageVector = Icons.Filled.VerifiedUser, contentDescription = null, modifier = Modifier.size(14.dp), tint = MaterialTheme.colorScheme.tertiary, ) Spacer(modifier = Modifier.width(4.dp)) Text( text = stringResource(Res.string.verified_build), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.tertiary, fontWeight = FontWeight.SemiBold, ) } else -> {} } } } } private fun normalizeVersion(version: String): String = version.removePrefix("v").removePrefix("V").trim() private fun formatFileSize(bytes: Long): String = when { bytes >= 1_073_741_824 -> "%.1f GB".format(bytes / 1_073_741_824.0) bytes >= 1_048_576 -> "%.1f MB".format(bytes / 1_048_576.0) bytes >= 1_024 -> "%.1f KB".format(bytes / 1_024.0) else -> "$bytes B" } @Preview @Composable fun SmartInstallButtonDownloadingPreview() { val liquidState = rememberLiquidState() CompositionLocalProvider(LocalTopbarLiquidState provides liquidState) { SmartInstallButton( isDownloading = true, isInstalling = false, progress = 45, primaryAsset = GithubAsset( id = 1L, name = "app-arm64-v8a.apk", contentType = "application/vnd.android.package-archive", size = 50_000_000L, downloadUrl = "https://example.com/app.apk", uploader = GithubUser( id = 1L, login = "developer", avatarUrl = "", htmlUrl = "", ), ), onAction = {}, isLiquidGlassEnabled = true, state = DetailsState( isDownloading = true, downloadStage = DownloadStage.DOWNLOADING, downloadProgressPercent = 45, ), ) } } ================================================ FILE: feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/StatItem.kt ================================================ package zed.rainxch.details.presentation.components import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @Composable fun StatItem( label: String, stat: Int, modifier: Modifier = Modifier, ) { OutlinedCard( modifier = modifier, colors = CardDefaults.outlinedCardColors( containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, ), ) { Column( modifier = Modifier.padding(12.dp), ) { Text( text = label, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.outline, maxLines = 1, softWrap = false, ) Text( text = stat.toString(), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Black, color = MaterialTheme.colorScheme.onBackground, ) } } } ================================================ FILE: feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/TranslationControls.kt ================================================ package zed.rainxch.details.presentation.components import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement 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.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.GTranslate import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text 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.unit.dp import org.jetbrains.compose.resources.stringResource import zed.rainxch.details.presentation.model.TranslationState import zed.rainxch.githubstore.core.presentation.res.* @Composable fun TranslationControls( translationState: TranslationState, onTranslateClick: () -> Unit, onLanguagePickerClick: () -> Unit, onToggleTranslation: () -> Unit, modifier: Modifier = Modifier, ) { AnimatedContent( targetState = translationState.controlState, modifier = modifier, transitionSpec = { (fadeIn() + slideInHorizontally { it / 3 }) togetherWith (fadeOut() + slideOutHorizontally { -it / 3 }) }, label = "translation_controls", ) { state -> when (state) { TranslationControlState.IDLE -> { IdleControls( onTranslateClick = onTranslateClick, onLanguagePickerClick = onLanguagePickerClick, ) } TranslationControlState.TRANSLATING -> { TranslatingIndicator() } TranslationControlState.SHOWING_TRANSLATION -> { TranslatedControls( displayName = translationState.targetLanguageDisplayName, isShowingTranslation = true, onToggle = onToggleTranslation, onLanguagePickerClick = onLanguagePickerClick, ) } TranslationControlState.SHOWING_ORIGINAL -> { TranslatedControls( displayName = translationState.targetLanguageDisplayName, isShowingTranslation = false, onToggle = onToggleTranslation, onLanguagePickerClick = onLanguagePickerClick, ) } TranslationControlState.ERROR -> { ErrorControls( onRetry = onTranslateClick, onLanguagePickerClick = onLanguagePickerClick, ) } } } } @Composable private fun IdleControls( onTranslateClick: () -> Unit, onLanguagePickerClick: () -> Unit, ) { Row( modifier = Modifier .clip(RoundedCornerShape(20.dp)) .background(MaterialTheme.colorScheme.surfaceContainerHigh) .clickable(onClick = onTranslateClick) .padding(start = 10.dp, end = 4.dp, top = 4.dp, bottom = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( imageVector = Icons.Default.GTranslate, contentDescription = stringResource(Res.string.translate), tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(16.dp), ) Spacer(Modifier.width(5.dp)) Text( text = stringResource(Res.string.translate), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) LanguageDropdownButton(onLanguagePickerClick) } } @Composable private fun TranslatingIndicator() { Row( modifier = Modifier .clip(RoundedCornerShape(20.dp)) .background(MaterialTheme.colorScheme.primaryContainer) .padding(horizontal = 12.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), ) { CircularProgressIndicator( modifier = Modifier.size(14.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.onPrimaryContainer, ) Text( text = stringResource(Res.string.translating), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onPrimaryContainer, ) } } @Composable private fun TranslatedControls( displayName: String?, isShowingTranslation: Boolean, onToggle: () -> Unit, onLanguagePickerClick: () -> Unit, ) { Row( verticalAlignment = Alignment.CenterVertically, ) { Row( modifier = Modifier .clip(RoundedCornerShape(20.dp)) .background( if (isShowingTranslation) { MaterialTheme.colorScheme.primaryContainer } else { MaterialTheme.colorScheme.surfaceContainerHigh }, ).clickable(onClick = onToggle) .padding(start = 10.dp, end = 4.dp, top = 4.dp, bottom = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { AnimatedVisibility( visible = isShowingTranslation, enter = fadeIn() + scaleIn(), exit = fadeOut() + scaleOut(), ) { Row { Icon( imageVector = Icons.Default.GTranslate, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.size(14.dp), ) Spacer(Modifier.width(4.dp)) } } Text( text = if (isShowingTranslation) { stringResource(Res.string.show_original) } else { displayName ?: stringResource(Res.string.translate) }, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Medium, color = if (isShowingTranslation) { MaterialTheme.colorScheme.onPrimaryContainer } else { MaterialTheme.colorScheme.onSurfaceVariant }, ) LanguageDropdownButton( onClick = onLanguagePickerClick, tint = if (isShowingTranslation) { MaterialTheme.colorScheme.onPrimaryContainer } else { MaterialTheme.colorScheme.onSurfaceVariant }, ) } } } @Composable private fun ErrorControls( onRetry: () -> Unit, onLanguagePickerClick: () -> Unit, ) { Row( modifier = Modifier .clip(RoundedCornerShape(20.dp)) .background(MaterialTheme.colorScheme.errorContainer) .clickable(onClick = onRetry) .padding(start = 10.dp, end = 4.dp, top = 4.dp, bottom = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( imageVector = Icons.Default.Refresh, contentDescription = stringResource(Res.string.translation_error_retry), tint = MaterialTheme.colorScheme.onErrorContainer, modifier = Modifier.size(14.dp), ) Spacer(Modifier.width(4.dp)) Text( text = stringResource(Res.string.translation_error_retry), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onErrorContainer, ) LanguageDropdownButton( onClick = onLanguagePickerClick, tint = MaterialTheme.colorScheme.onErrorContainer, ) } } @Composable private fun LanguageDropdownButton( onClick: () -> Unit, tint: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.onSurfaceVariant, ) { Icon( imageVector = Icons.Default.ArrowDropDown, contentDescription = stringResource(Res.string.change_language), tint = tint, modifier = Modifier .size(24.dp) .clip(RoundedCornerShape(12.dp)) .clickable(onClick = onClick) .padding(2.dp), ) } private enum class TranslationControlState { IDLE, TRANSLATING, SHOWING_TRANSLATION, SHOWING_ORIGINAL, ERROR, } private val TranslationState.controlState: TranslationControlState get() = when { isTranslating -> TranslationControlState.TRANSLATING error != null && translatedText == null -> TranslationControlState.ERROR isShowingTranslation && translatedText != null -> TranslationControlState.SHOWING_TRANSLATION !isShowingTranslation && translatedText != null -> TranslationControlState.SHOWING_ORIGINAL else -> TranslationControlState.IDLE } ================================================ FILE: feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/VersionPicker.kt ================================================ package zed.rainxch.details.presentation.components import androidx.compose.foundation.clickable 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.heightIn import androidx.compose.foundation.layout.navigationBarsPadding 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.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.UnfoldMore import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import zed.rainxch.core.domain.model.GithubRelease import zed.rainxch.details.presentation.DetailsAction import zed.rainxch.githubstore.core.presentation.res.Res import zed.rainxch.githubstore.core.presentation.res.latest_badge import zed.rainxch.githubstore.core.presentation.res.no_version_selected import zed.rainxch.githubstore.core.presentation.res.not_available import zed.rainxch.githubstore.core.presentation.res.pre_release_badge import zed.rainxch.githubstore.core.presentation.res.select_version import zed.rainxch.githubstore.core.presentation.res.versions_title @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun VersionPicker( selectedRelease: GithubRelease?, filteredReleases: List, isPickerVisible: Boolean, onAction: (DetailsAction) -> Unit, modifier: Modifier = Modifier, ) { val isPickerEnabled by remember(filteredReleases) { derivedStateOf { filteredReleases.isNotEmpty() } } Column( modifier = modifier.wrapContentHeight(), verticalArrangement = Arrangement.spacedBy(4.dp), ) { Text( text = stringResource(Res.string.versions_title), style = MaterialTheme.typography.labelLargeEmphasized, color = MaterialTheme.colorScheme.tertiary, modifier = Modifier.padding(horizontal = 4.dp), ) OutlinedCard( onClick = { onAction(DetailsAction.ToggleVersionPicker) }, enabled = isPickerEnabled, modifier = Modifier.fillMaxWidth(), ) { Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 12.dp) .heightIn(min = 36.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f)) { Text( text = selectedRelease?.tagName ?: stringResource(Res.string.no_version_selected), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, overflow = TextOverflow.Clip, maxLines = 1, ) selectedRelease?.name?.let { name -> if (name != selectedRelease.tagName) { Text( text = name, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } } } Icon( imageVector = Icons.Default.UnfoldMore, contentDescription = stringResource(Res.string.select_version), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } if (isPickerVisible) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) ModalBottomSheet( onDismissRequest = { onAction(DetailsAction.ToggleVersionPicker) }, sheetState = sheetState, ) { Column( modifier = Modifier .fillMaxWidth() .navigationBarsPadding(), ) { Text( text = stringResource(Res.string.versions_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), ) HorizontalDivider() if (filteredReleases.isEmpty()) { Text( text = stringResource(Res.string.not_available), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(16.dp), ) } else { val latestReleaseId by remember(filteredReleases) { derivedStateOf { filteredReleases.firstOrNull()?.id } } LazyColumn( modifier = Modifier.fillMaxWidth(), contentPadding = PaddingValues(vertical = 8.dp), ) { items( items = filteredReleases, key = { it.id }, ) { release -> VersionListItem( release = release, isSelected = release.id == selectedRelease?.id, isLatest = release.id == latestReleaseId, onClick = { onAction(DetailsAction.SelectRelease(release)) }, ) } } } } } } } @Composable private fun VersionListItem( release: GithubRelease, isSelected: Boolean, isLatest: Boolean, onClick: () -> Unit, ) { Row( modifier = Modifier .fillMaxWidth() .clickable( onClickLabel = stringResource(Res.string.select_version), onClick = onClick, ).padding(horizontal = 16.dp, vertical = 12.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f)) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Text( text = release.tagName, style = MaterialTheme.typography.titleSmall, fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, color = if (isSelected) { MaterialTheme.colorScheme.primary } else { MaterialTheme.colorScheme.onSurface }, ) if (isLatest) { Surface( shape = RoundedCornerShape(4.dp), color = MaterialTheme.colorScheme.primaryContainer, ) { Text( text = stringResource(Res.string.latest_badge), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), color = MaterialTheme.colorScheme.onPrimaryContainer, ) } } if (release.isPrerelease) { Surface( shape = RoundedCornerShape(4.dp), color = MaterialTheme.colorScheme.tertiaryContainer, ) { Text( text = stringResource(Res.string.pre_release_badge), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), color = MaterialTheme.colorScheme.onTertiaryContainer, ) } } } release.name?.let { name -> if (name != release.tagName) { Text( text = name, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } } Text( text = release.publishedAt.take(10), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline, ) } if (isSelected) { Spacer(Modifier.width(8.dp)) Icon( imageVector = Icons.Default.CheckCircle, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp), ) } } } ================================================ FILE: feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/VersionTypePicker.kt ================================================ package zed.rainxch.details.presentation.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.material3.FilterChip import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import zed.rainxch.details.domain.model.ReleaseCategory import zed.rainxch.details.presentation.DetailsAction import zed.rainxch.githubstore.core.presentation.res.Res import zed.rainxch.githubstore.core.presentation.res.category_all import zed.rainxch.githubstore.core.presentation.res.category_pre_release import zed.rainxch.githubstore.core.presentation.res.category_stable @Composable fun VersionTypePicker( selectedCategory: ReleaseCategory, onAction: (DetailsAction) -> Unit, modifier: Modifier = Modifier, ) { LazyRow( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier.fillMaxWidth(), ) { items(ReleaseCategory.entries) { category -> FilterChip( selected = category == selectedCategory, onClick = { onAction(DetailsAction.SelectReleaseCategory(category)) }, label = { Text( text = when (category) { ReleaseCategory.STABLE -> stringResource(Res.string.category_stable) ReleaseCategory.PRE_RELEASE -> stringResource(Res.string.category_pre_release) ReleaseCategory.ALL -> stringResource(Res.string.category_all) }, ) }, ) } } } ================================================ FILE: feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/About.kt ================================================ package zed.rainxch.details.presentation.components.sections import androidx.compose.animation.AnimatedContent import androidx.compose.animation.animateContentSize import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith 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.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton 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.clipToBounds import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.mikepenz.markdown.compose.Markdown import com.mikepenz.markdown.model.ImageTransformer import io.github.fletchmckee.liquid.liquefiable import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor import org.jetbrains.compose.resources.stringResource import zed.rainxch.details.presentation.components.TranslationControls import zed.rainxch.details.presentation.model.TranslationState import zed.rainxch.details.presentation.utils.LocalTopbarLiquidState import zed.rainxch.details.presentation.utils.MarkdownImageTransformer import zed.rainxch.details.presentation.utils.rememberMarkdownColors import zed.rainxch.details.presentation.utils.rememberMarkdownTypography import zed.rainxch.githubstore.core.presentation.res.* fun LazyListScope.about( readmeMarkdown: String, readmeLanguage: String?, isExpanded: Boolean, isLiquidGlassEnabled: Boolean, onToggleExpanded: () -> Unit, collapsedHeight: Dp, translationState: TranslationState, onTranslateClick: () -> Unit, onLanguagePickerClick: () -> Unit, onToggleTranslation: () -> Unit, ) { item { val liquidState = LocalTopbarLiquidState.current HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) Spacer(Modifier.height(16.dp)) Row( modifier = Modifier .fillMaxWidth() .padding(bottom = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Text( text = stringResource(Res.string.about_this_app), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onBackground, fontWeight = FontWeight.Bold, modifier = Modifier.then( if (isLiquidGlassEnabled) { Modifier.liquefiable(liquidState) } else { Modifier }, ), ) readmeLanguage?.let { Text( text = it, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline, modifier = Modifier.then( if (isLiquidGlassEnabled) { Modifier.liquefiable(liquidState) } else { Modifier }, ), ) } } TranslationControls( translationState = translationState, onTranslateClick = onTranslateClick, onLanguagePickerClick = onLanguagePickerClick, onToggleTranslation = onToggleTranslation, ) } } item { val liquidState = LocalTopbarLiquidState.current val displayContent = if (translationState.isShowingTranslation && translationState.translatedText != null) { translationState.translatedText } else { readmeMarkdown } AnimatedContent( targetState = displayContent, transitionSpec = { fadeIn() togetherWith fadeOut() }, label = "about_content", ) { content -> ExpandableMarkdownContent( content = content, isExpanded = isExpanded, onToggleExpanded = onToggleExpanded, imageTransformer = MarkdownImageTransformer, collapsedHeight = collapsedHeight, fadeColor = MaterialTheme.colorScheme.background, modifier = Modifier .fillMaxWidth() .then( if (isLiquidGlassEnabled) { Modifier.liquefiable(liquidState) } else { Modifier }, ) .animateContentSize(), ) } } } @Composable fun ExpandableMarkdownContent( content: String, isExpanded: Boolean, onToggleExpanded: () -> Unit, imageTransformer: ImageTransformer, collapsedHeight: Dp, fadeColor: Color, modifier: Modifier = Modifier, ) { val density = LocalDensity.current val colors = rememberMarkdownColors() val typography = rememberMarkdownTypography() val flavour = remember { GFMFlavourDescriptor() } val collapsedHeightPx = with(density) { collapsedHeight.toPx() } var contentHeightPx by remember(content, collapsedHeightPx) { mutableStateOf(0f) } val needsExpansion = contentHeightPx > collapsedHeightPx && collapsedHeightPx > 0f Column( modifier = modifier.animateContentSize(), ) { Box { Surface( color = Color.Transparent, contentColor = MaterialTheme.colorScheme.onBackground, modifier = if (!isExpanded && needsExpansion) { Modifier.heightIn(max = collapsedHeight).clipToBounds() } else { Modifier }, ) { Markdown( content = content, colors = colors, typography = typography, flavour = flavour, imageTransformer = imageTransformer, modifier = Modifier .fillMaxWidth() .onGloballyPositioned { coordinates -> val measured = coordinates.size.height.toFloat() if (measured > contentHeightPx) { contentHeightPx = measured } }, ) } if (!isExpanded && needsExpansion) { Box( modifier = Modifier .align(Alignment.BottomCenter) .fillMaxWidth() .height(80.dp) .background( Brush.verticalGradient( 0f to fadeColor.copy(alpha = 0f), 1f to fadeColor, ), ), ) } } if (needsExpansion) { TextButton( onClick = onToggleExpanded, modifier = Modifier.align(Alignment.CenterHorizontally), ) { Text( text = if (isExpanded) { stringResource(Res.string.show_less) } else { stringResource(Res.string.read_more) }, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, ) } } } } ================================================ FILE: feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/Header.kt ================================================ package zed.rainxch.details.presentation.components.sections 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.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.filled.Security import androidx.compose.material.icons.filled.Update import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import io.github.fletchmckee.liquid.liquefiable import org.jetbrains.compose.resources.stringResource import zed.rainxch.details.presentation.DetailsAction import zed.rainxch.details.presentation.DetailsState import zed.rainxch.details.presentation.components.AppHeader import zed.rainxch.details.presentation.components.ReleaseAssetsPicker import zed.rainxch.details.presentation.components.SmartInstallButton import zed.rainxch.details.presentation.components.VersionPicker import zed.rainxch.details.presentation.components.VersionTypePicker import zed.rainxch.details.presentation.utils.LocalTopbarLiquidState import zed.rainxch.githubstore.core.presentation.res.Res import zed.rainxch.githubstore.core.presentation.res.appmanager_description import zed.rainxch.githubstore.core.presentation.res.external_installer_description import zed.rainxch.githubstore.core.presentation.res.inspect_with_appmanager import zed.rainxch.githubstore.core.presentation.res.obtainium_description import zed.rainxch.githubstore.core.presentation.res.open_in_obtainium import zed.rainxch.githubstore.core.presentation.res.open_with_external_installer fun LazyListScope.header( state: DetailsState, onAction: (DetailsAction) -> Unit, ) { item { val liquidState = LocalTopbarLiquidState.current if (state.repository != null) { AppHeader( author = state.userProfile, release = state.selectedRelease, repository = state.repository, installedApp = state.installedApp, downloadStage = state.downloadStage, downloadProgress = state.downloadProgressPercent, modifier = Modifier.then( if (state.isLiquidGlassEnabled) { Modifier.liquefiable(liquidState) } else { Modifier }, ), ) } } // versions type list if (state.allReleases.isNotEmpty()) { item { VersionTypePicker( selectedCategory = state.selectedReleaseCategory, onAction = onAction, modifier = Modifier.fillMaxWidth().animateItem(), ) } } // version and installable release if (state.allReleases.isNotEmpty() || state.installableAssets.isNotEmpty()) { item { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically, ) { ReleaseAssetsPicker( assetsList = state.installableAssets, selectedAsset = state.primaryAsset, isPickerVisible = state.isReleaseSelectorVisible, onAction = onAction, modifier = Modifier.weight(.65f), ) VersionPicker( selectedRelease = state.selectedRelease, filteredReleases = state.filteredReleases, isPickerVisible = state.isVersionPickerVisible, onAction = onAction, modifier = Modifier.weight(.35f), ) } } } item { val liquidState = LocalTopbarLiquidState.current Box( modifier = Modifier.fillMaxWidth(), ) { SmartInstallButton( isDownloading = state.isDownloading, isInstalling = state.isInstalling, isLiquidGlassEnabled = state.isLiquidGlassEnabled, progress = state.downloadProgressPercent, primaryAsset = state.primaryAsset, state = state, onAction = onAction, ) DropdownMenu( expanded = state.isInstallDropdownExpanded, onDismissRequest = { onAction(DetailsAction.OnToggleInstallDropdown) }, offset = DpOffset(x = 0.dp, y = 20.dp), ) { DropdownMenuItem( text = { Column { Text( text = stringResource(Res.string.open_in_obtainium), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, ) Text( text = stringResource(Res.string.obtainium_description), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } }, onClick = { onAction(DetailsAction.OpenInObtainium) }, leadingIcon = { Icon( imageVector = Icons.Default.Update, contentDescription = null, modifier = Modifier.size(24.dp), ) }, modifier = Modifier.then( if (state.isLiquidGlassEnabled) { Modifier.liquefiable(liquidState) } else { Modifier }, ), ) Spacer(Modifier.height(8.dp)) DropdownMenuItem( text = { Column { Text( text = stringResource(Res.string.inspect_with_appmanager), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, ) Text( text = stringResource(Res.string.appmanager_description), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } }, onClick = { onAction(DetailsAction.OpenInAppManager) }, leadingIcon = { Icon( imageVector = Icons.Default.Security, contentDescription = null, modifier = Modifier.size(24.dp), ) }, modifier = Modifier.then( if (state.isLiquidGlassEnabled) { Modifier.liquefiable(liquidState) } else { Modifier }, ), ) Spacer(Modifier.height(8.dp)) DropdownMenuItem( text = { Column { Text( text = stringResource(Res.string.open_with_external_installer), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, ) Text( text = stringResource(Res.string.external_installer_description), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } }, onClick = { onAction(DetailsAction.InstallWithExternalApp) }, leadingIcon = { Icon( imageVector = Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null, modifier = Modifier.size(24.dp), ) }, modifier = Modifier.then( if (state.isLiquidGlassEnabled) { Modifier.liquefiable(liquidState) } else { Modifier }, ), ) } } } } ================================================ FILE: feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/Logs.kt ================================================ package zed.rainxch.details.presentation.components.sections import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import io.github.fletchmckee.liquid.liquefiable import org.jetbrains.compose.resources.stringResource import zed.rainxch.details.presentation.DetailsState import zed.rainxch.details.presentation.model.LogResult import zed.rainxch.details.presentation.utils.LocalTopbarLiquidState import zed.rainxch.details.presentation.utils.asText import zed.rainxch.githubstore.core.presentation.res.* fun LazyListScope.logs(state: DetailsState) { item { val liquidState = LocalTopbarLiquidState.current HorizontalDivider() Text( text = stringResource(Res.string.install_logs), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onBackground, modifier = Modifier .padding(vertical = 8.dp) .then( if (state.isLiquidGlassEnabled) { Modifier.liquefiable(liquidState) } else { Modifier }, ), fontWeight = FontWeight.Bold, ) } items(state.installLogs) { log -> val liquidState = LocalTopbarLiquidState.current Text( text = "> ${log.result.asText()}: ${log.assetName}", style = MaterialTheme.typography.labelSmall.copy( fontStyle = FontStyle.Italic, ), color = if (log.result is LogResult.Error) { MaterialTheme.colorScheme.error } else { MaterialTheme.colorScheme.outline }, modifier = Modifier.then( if (state.isLiquidGlassEnabled) { Modifier.liquefiable(liquidState) } else { Modifier }, ), ) } } ================================================ FILE: feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/Owner.kt ================================================ package zed.rainxch.details.presentation.components.sections 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.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Text 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 androidx.compose.ui.unit.dp import com.skydoves.landscapist.coil3.CoilImage import io.github.fletchmckee.liquid.liquefiable import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import zed.rainxch.core.domain.model.GithubUserProfile import zed.rainxch.details.presentation.DetailsAction import zed.rainxch.details.presentation.utils.LocalTopbarLiquidState import zed.rainxch.githubstore.core.presentation.res.* @OptIn(ExperimentalMaterial3ExpressiveApi::class) fun LazyListScope.author( isLiquidGlassEnabled: Boolean, author: GithubUserProfile?, onAction: (DetailsAction) -> Unit, ) { item { val liquidState = LocalTopbarLiquidState.current HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) Spacer(Modifier.height(16.dp)) Text( text = stringResource(Res.string.author), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onBackground, modifier = Modifier .padding(bottom = 12.dp) .then( if (isLiquidGlassEnabled) { Modifier.liquefiable(liquidState) } else { Modifier }, ), fontWeight = FontWeight.Bold, ) OutlinedCard( onClick = { author?.login?.let { author -> onAction( DetailsAction.OpenDeveloperProfile( author, ), ) } }, colors = CardDefaults.outlinedCardColors( containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, ), shape = RoundedCornerShape(32.dp), ) { Row( modifier = Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, ) { CoilImage( imageModel = { author?.avatarUrl }, modifier = Modifier .size(80.dp) .clip(CircleShape) .then( if (isLiquidGlassEnabled) { Modifier.liquefiable(liquidState) } else { Modifier }, ), loading = { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { CircularWavyProgressIndicator() } }, ) Column( modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp), ) { author?.login?.let { Text( text = it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.then( if (isLiquidGlassEnabled) { Modifier.liquefiable(liquidState) } else { Modifier }, ), ) } author?.bio?.let { bio -> Text( text = bio, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.outline, maxLines = 2, softWrap = false, overflow = TextOverflow.Ellipsis, modifier = Modifier.then( if (isLiquidGlassEnabled) { Modifier.liquefiable(liquidState) } else { Modifier }, ), ) } Spacer(Modifier.height(4.dp)) author?.htmlUrl?.let { Row( modifier = Modifier.clickable { onAction(DetailsAction.OpenAuthorInBrowser) }, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Icon( painter = painterResource(Res.drawable.ic_github), contentDescription = null, modifier = Modifier.size(24.dp), tint = MaterialTheme.colorScheme.primary, ) Text( text = stringResource(Res.string.profile), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary, ) } } } author?.login?.let { author -> IconButton( onClick = { onAction(DetailsAction.OpenDeveloperProfile(author)) }, colors = IconButtonDefaults.iconButtonColors( containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, contentColor = MaterialTheme.colorScheme.onSurface, ), ) { Icon( imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = stringResource(Res.string.open_developer_profile), modifier = Modifier.size(24.dp), ) } } } } } } ================================================ FILE: feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/ReportIssue.kt ================================================ package zed.rainxch.details.presentation.components.sections import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.BugReport import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Text import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import zed.rainxch.githubstore.core.presentation.res.Res import zed.rainxch.githubstore.core.presentation.res.open_github_link import zed.rainxch.githubstore.core.presentation.res.open_in_browser import zed.rainxch.githubstore.core.presentation.res.report_issue fun LazyListScope.reportIssue(repoUrl: String) { item { val uriHandler = LocalUriHandler.current OutlinedCard( onClick = { uriHandler.openUri("${repoUrl.trimEnd('/')}/issues") }, colors = CardDefaults.outlinedCardColors( containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, ), shape = RoundedCornerShape(32.dp), ) { Row( modifier = Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( imageVector = Icons.Default.BugReport, contentDescription = stringResource(Res.string.report_issue), tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(32.dp), ) Text( text = stringResource(Res.string.report_issue), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f), ) Icon( imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = stringResource(Res.string.open_in_browser), tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(20.dp), ) } } } } ================================================ FILE: feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/Stats.kt ================================================ package zed.rainxch.details.presentation.components.sections import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import io.github.fletchmckee.liquid.liquefiable import org.jetbrains.compose.resources.stringResource import zed.rainxch.details.domain.model.RepoStats import zed.rainxch.details.presentation.components.StatItem import zed.rainxch.details.presentation.utils.LocalTopbarLiquidState import zed.rainxch.githubstore.core.presentation.res.* fun LazyListScope.stats( isLiquidGlassEnabled: Boolean, repoStats: RepoStats, ) { item { val liquidState = LocalTopbarLiquidState.current Spacer(Modifier.height(16.dp)) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { StatItem( label = stringResource(Res.string.forks), stat = repoStats.forks, modifier = Modifier .weight(1.5f) .then( if (isLiquidGlassEnabled) { Modifier.liquefiable(liquidState) } else { Modifier }, ), ) StatItem( label = stringResource(Res.string.stars), stat = repoStats.stars, modifier = Modifier .weight(2f) .then( if (isLiquidGlassEnabled) { Modifier.liquefiable(liquidState) } else { Modifier }, ), ) StatItem( label = stringResource(Res.string.issues), stat = repoStats.openIssues, modifier = Modifier .weight(1f) .then( if (isLiquidGlassEnabled) { Modifier.liquefiable(liquidState) } else { Modifier }, ), ) } } } ================================================ FILE: feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/WhatsNew.kt ================================================ package zed.rainxch.details.presentation.components.sections import androidx.compose.animation.AnimatedContent import androidx.compose.animation.animateContentSize import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith 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.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf 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.clipToBounds import androidx.compose.ui.graphics.Brush import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.mikepenz.markdown.compose.Markdown import io.github.fletchmckee.liquid.LiquidState import io.github.fletchmckee.liquid.liquefiable import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor import org.jetbrains.compose.resources.stringResource import zed.rainxch.core.domain.model.GithubRelease import zed.rainxch.details.presentation.components.TranslationControls import zed.rainxch.details.presentation.model.TranslationState import zed.rainxch.details.presentation.utils.LocalTopbarLiquidState import zed.rainxch.details.presentation.utils.MarkdownImageTransformer import zed.rainxch.details.presentation.utils.rememberMarkdownColors import zed.rainxch.details.presentation.utils.rememberMarkdownTypography import zed.rainxch.githubstore.core.presentation.res.* fun LazyListScope.whatsNew( release: GithubRelease, isExpanded: Boolean, isLiquidGlassEnabled: Boolean, onToggleExpanded: () -> Unit, collapsedHeight: Dp, translationState: TranslationState, onTranslateClick: () -> Unit, onLanguagePickerClick: () -> Unit, onToggleTranslation: () -> Unit, ) { item { val liquidState = LocalTopbarLiquidState.current HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) Spacer(Modifier.height(16.dp)) Row( modifier = Modifier .fillMaxWidth() .padding(bottom = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { Text( text = stringResource(Res.string.whats_new), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.then( if (isLiquidGlassEnabled) { Modifier.liquefiable(liquidState) } else { Modifier }, ), fontWeight = FontWeight.Bold, ) TranslationControls( translationState = translationState, onTranslateClick = onTranslateClick, onLanguagePickerClick = onLanguagePickerClick, onToggleTranslation = onToggleTranslation, ) } Spacer(Modifier.height(8.dp)) Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceContainerLow, ), ) { Column( modifier = Modifier .fillMaxWidth() .padding(16.dp), ) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { Text( release.tagName, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, modifier = Modifier.then( if (isLiquidGlassEnabled) { Modifier.liquefiable(liquidState) } else { Modifier }, ), ) Text( release.publishedAt.take(10), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.then( if (isLiquidGlassEnabled) { Modifier.liquefiable(liquidState) } else { Modifier }, ), ) } } } } item { val liquidState = LocalTopbarLiquidState.current Spacer(Modifier.height(12.dp)) ExpandableMarkdownContent( translationState = translationState, release = release, collapsedHeight = collapsedHeight, isExpanded = isExpanded, isLiquidGlassEnabled = isLiquidGlassEnabled, liquidState = liquidState, onToggleExpanded = onToggleExpanded, ) } } @Composable private fun ExpandableMarkdownContent( translationState: TranslationState, release: GithubRelease, collapsedHeight: Dp, isExpanded: Boolean, isLiquidGlassEnabled: Boolean, liquidState: LiquidState, onToggleExpanded: () -> Unit, ) { val displayContent = if (translationState.isShowingTranslation && translationState.translatedText != null) { translationState.translatedText } else { release.description ?: stringResource(Res.string.no_release_notes) } val density = LocalDensity.current val colors = rememberMarkdownColors() val typography = rememberMarkdownTypography() val flavour = remember { GFMFlavourDescriptor() } val cardColor = MaterialTheme.colorScheme.surfaceContainerLow AnimatedContent( targetState = displayContent, transitionSpec = { fadeIn() togetherWith fadeOut() }, label = "whats_new_content", ) { content -> val collapsedHeightPx = with(density) { collapsedHeight.toPx() } var contentHeightPx by remember(content, collapsedHeightPx) { mutableFloatStateOf(0f) } val needsExpansion = remember(contentHeightPx, collapsedHeightPx) { contentHeightPx > collapsedHeightPx && collapsedHeightPx > 0f } Column( modifier = Modifier.animateContentSize(), ) { Box { Box( modifier = if (!isExpanded && needsExpansion) { Modifier.heightIn(max = collapsedHeight).clipToBounds() } else { Modifier }, ) { Markdown( content = content, colors = colors, typography = typography, flavour = flavour, imageTransformer = MarkdownImageTransformer, modifier = Modifier .fillMaxWidth() .then( if (isLiquidGlassEnabled) { Modifier.liquefiable(liquidState) } else { Modifier }, ) .onGloballyPositioned { coordinates -> val measured = coordinates.size.height.toFloat() if (measured > contentHeightPx) { contentHeightPx = measured } }, ) } if (!isExpanded && needsExpansion) { Box( modifier = Modifier .align(Alignment.BottomCenter) .fillMaxWidth() .height(80.dp) .background( Brush.verticalGradient( 0f to cardColor.copy(alpha = 0f), 1f to cardColor, ), ), ) } } if (needsExpansion) { TextButton( onClick = onToggleExpanded, modifier = Modifier.align(Alignment.CenterHorizontally), ) { Text( text = if (isExpanded) { stringResource(Res.string.show_less) } else { stringResource(Res.string.read_more) }, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, ) } } } } } ================================================ FILE: feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/states/ErrorState.kt ================================================ package zed.rainxch.details.presentation.components.states import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import org.jetbrains.compose.resources.stringResource import zed.rainxch.core.presentation.components.GithubStoreButton import zed.rainxch.details.presentation.DetailsAction import zed.rainxch.githubstore.core.presentation.res.* @Composable fun ErrorState( errorMessage: String, onAction: (DetailsAction) -> Unit, ) { Column( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { Text( text = stringResource(Res.string.error_loading_details), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onBackground, textAlign = TextAlign.Center, ) Text( text = errorMessage, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.error, ) GithubStoreButton( text = stringResource(Res.string.retry), onClick = { onAction(DetailsAction.Retry) }, ) } } ================================================ FILE: feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/model/AttestationStatus.kt ================================================ package zed.rainxch.details.presentation.model enum class AttestationStatus { UNCHECKED, CHECKING, VERIFIED, UNVERIFIED, } ================================================ FILE: feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/model/DownloadStage.kt ================================================ package zed.rainxch.details.presentation.model enum class DownloadStage { IDLE, DOWNLOADING, VERIFYING, INSTALLING, } ================================================ FILE: feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/model/InstallLogItem.kt ================================================ package zed.rainxch.details.presentation.model data class InstallLogItem( val timeIso: String, val assetName: String, val assetSizeBytes: Long, val releaseTag: String, val result: LogResult, ) ================================================ FILE: feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/model/LogResult.kt ================================================ package zed.rainxch.details.presentation.model sealed class LogResult { data object DownloadStarted : LogResult() data object UpdateStarted : LogResult() data object Downloaded : LogResult() data object InstallStarted : LogResult() data object Installed : LogResult() data object Updated : LogResult() data object Cancelled : LogResult() data object PreparingForAppManager : LogResult() data object OpenedInAppManager : LogResult() data object PermissionBlocked : LogResult() data object OpenedInExternalInstaller : LogResult() data class Error( val message: String?, ) : LogResult() data class Info( val message: String, ) : LogResult() } ================================================ FILE: feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/model/ShowDowngradeWarning.kt ================================================ package zed.rainxch.details.presentation.model data class DowngradeWarning( val packageName: String, val currentVersion: String, val targetVersion: String, ) ================================================ FILE: feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/model/SigningKeyWarning.kt ================================================ package zed.rainxch.details.presentation.model data class SigningKeyWarning( val packageName: String, val expectedFingerprint: String, val actualFingerprint: String, val pendingDownloadUrl: String, val pendingAssetName: String, val pendingSizeBytes: Long, val pendingReleaseTag: String, val pendingIsUpdate: Boolean, val pendingFilePath: String, ) ================================================ FILE: feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/model/SupportedLanguages.kt ================================================ package zed.rainxch.details.presentation.model import zed.rainxch.details.domain.model.SupportedLanguage object SupportedLanguages { val all: List = listOf( SupportedLanguage("ar", "Arabic"), SupportedLanguage("bn", "Bengali"), SupportedLanguage("zh-CN", "Chinese (Simplified)"), SupportedLanguage("zh-TW", "Chinese (Traditional)"), SupportedLanguage("cs", "Czech"), SupportedLanguage("da", "Danish"), SupportedLanguage("nl", "Dutch"), SupportedLanguage("en", "English"), SupportedLanguage("fi", "Finnish"), SupportedLanguage("fr", "French"), SupportedLanguage("de", "German"), SupportedLanguage("el", "Greek"), SupportedLanguage("he", "Hebrew"), SupportedLanguage("hi", "Hindi"), SupportedLanguage("hu", "Hungarian"), SupportedLanguage("id", "Indonesian"), SupportedLanguage("it", "Italian"), SupportedLanguage("ja", "Japanese"), SupportedLanguage("ko", "Korean"), SupportedLanguage("ms", "Malay"), SupportedLanguage("no", "Norwegian"), SupportedLanguage("pl", "Polish"), SupportedLanguage("pt", "Portuguese"), SupportedLanguage("pt-BR", "Portuguese (Brazil)"), SupportedLanguage("ro", "Romanian"), SupportedLanguage("ru", "Russian"), SupportedLanguage("es", "Spanish"), SupportedLanguage("sv", "Swedish"), SupportedLanguage("th", "Thai"), SupportedLanguage("tr", "Turkish"), SupportedLanguage("uk", "Ukrainian"), SupportedLanguage("uz", "Uzbek"), SupportedLanguage("vi", "Vietnamese"), ) } ================================================ FILE: feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/model/TranslationState.kt ================================================ package zed.rainxch.details.presentation.model data class TranslationState( val isTranslating: Boolean = false, val translatedText: String? = null, val isShowingTranslation: Boolean = false, val targetLanguageCode: String? = null, val targetLanguageDisplayName: String? = null, val detectedSourceLanguage: String? = null, val error: String? = null, ) ================================================ FILE: feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/model/TranslationTarget.kt ================================================ package zed.rainxch.details.presentation.model sealed interface TranslationTarget { data object About : TranslationTarget data object WhatsNew : TranslationTarget } ================================================ FILE: feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/utils/LocalTopbarLiquidState.kt ================================================ package zed.rainxch.details.presentation.utils import androidx.compose.runtime.compositionLocalOf import io.github.fletchmckee.liquid.LiquidState internal val LocalTopbarLiquidState = compositionLocalOf { error("State not declared") } ================================================ FILE: feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/utils/LogResultAsText.kt ================================================ package zed.rainxch.details.presentation.utils import androidx.compose.runtime.Composable import org.jetbrains.compose.resources.stringResource import zed.rainxch.details.presentation.model.LogResult import zed.rainxch.githubstore.core.presentation.res.* @Composable fun LogResult.asText(): String = when (this) { LogResult.DownloadStarted -> { stringResource(Res.string.log_download_started) } LogResult.Downloaded -> { stringResource(Res.string.log_downloaded) } LogResult.InstallStarted -> { stringResource(Res.string.log_install_started) } LogResult.Installed -> { stringResource(Res.string.log_installed) } LogResult.Updated -> { stringResource(Res.string.log_updated) } LogResult.Cancelled -> { stringResource(Res.string.log_cancelled) } LogResult.OpenedInAppManager -> { stringResource(Res.string.log_opened_appmanager) } is LogResult.Error -> { message?.let { stringResource(Res.string.log_error_with_message, it) } ?: stringResource(Res.string.log_error) } is LogResult.Info -> { message } LogResult.PreparingForAppManager -> { stringResource(Res.string.log_prepare_appmanager) } LogResult.UpdateStarted -> { stringResource(Res.string.log_update_started) } LogResult.PermissionBlocked -> { stringResource(Res.string.log_permission_blocked) } LogResult.OpenedInExternalInstaller -> { stringResource(Res.string.log_opened_external_installer) } } ================================================ FILE: feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/utils/MarkdownImageTransformer.kt ================================================ package zed.rainxch.details.presentation.utils import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale import coil3.compose.rememberAsyncImagePainter import com.mikepenz.markdown.model.ImageData import com.mikepenz.markdown.model.ImageTransformer object MarkdownImageTransformer : ImageTransformer { @Composable override fun transform(link: String): ImageData? { if (link.isBlank()) { return null } val normalizedLink = if (link.contains("github.com") && link.contains("/blob/")) { link .replace("github.com", "raw.githubusercontent.com") .replace("/blob/", "/") } else { link } if (normalizedLink.endsWith(".svg", ignoreCase = true) || normalizedLink.contains(".svg?", ignoreCase = true) || normalizedLink.contains(".svg#", ignoreCase = true) ) { return null } if (!normalizedLink.startsWith("http://") && !normalizedLink.startsWith("https://") && !normalizedLink.startsWith("data:") ) { return null } val painter = rememberAsyncImagePainter( model = normalizedLink, ) return ImageData( painter = painter, modifier = Modifier.fillMaxWidth(), contentDescription = "Image", contentScale = ContentScale.Fit, ) } @Composable override fun intrinsicSize(painter: Painter): Size = painter.intrinsicSize } ================================================ FILE: feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/utils/MarkdownUtils.kt ================================================ package zed.rainxch.details.presentation.utils import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextLinkStyles 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.TextDecoration import com.mikepenz.markdown.model.DefaultMarkdownColors import com.mikepenz.markdown.model.DefaultMarkdownTypography import com.mikepenz.markdown.model.MarkdownColors import com.mikepenz.markdown.model.MarkdownTypography @Composable fun rememberMarkdownColors(): MarkdownColors { val colorScheme = MaterialTheme.colorScheme return DefaultMarkdownColors( text = colorScheme.onBackground, codeBackground = colorScheme.surfaceVariant.copy(alpha = 0.5f), inlineCodeBackground = colorScheme.surfaceVariant.copy(alpha = 0.5f), dividerColor = colorScheme.outlineVariant, tableBackground = colorScheme.surface, ) } @Composable fun rememberMarkdownTypography(): MarkdownTypography { val typography = MaterialTheme.typography val colorScheme = MaterialTheme.colorScheme return DefaultMarkdownTypography( h1 = typography.headlineMedium.copy(fontWeight = FontWeight.Bold), h2 = typography.titleLarge.copy(fontWeight = FontWeight.SemiBold), h3 = typography.titleMedium.copy(fontWeight = FontWeight.Medium), h4 = typography.titleSmall.copy(fontWeight = FontWeight.Medium), h5 = typography.titleSmall.copy(fontWeight = FontWeight.Normal), h6 = typography.labelLarge.copy(fontWeight = FontWeight.Bold), text = typography.bodyLarge, code = typography.bodyMedium.copy( fontFamily = FontFamily.Monospace, color = colorScheme.onSurfaceVariant, ), inlineCode = typography.bodyMedium.copy( fontFamily = FontFamily.Monospace, color = colorScheme.onSurfaceVariant, ), quote = typography.bodyLarge.copy( fontStyle = FontStyle.Italic, color = colorScheme.onSurfaceVariant, ), paragraph = typography.bodyLarge, ordered = typography.bodyLarge, bullet = typography.bodyLarge, list = typography.bodyLarge, textLink = TextLinkStyles( style = SpanStyle( color = colorScheme.primary, textDecoration = TextDecoration.Underline, ), ), table = typography.bodyMedium, ) } ================================================ FILE: feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/utils/SystemArchitecture.kt ================================================ package zed.rainxch.details.presentation.utils import zed.rainxch.core.domain.model.AssetArchitectureMatcher import zed.rainxch.core.domain.model.SystemArchitecture fun extractArchitectureFromName(name: String): String? = when (AssetArchitectureMatcher.detectArchitecture(name)) { SystemArchitecture.X86_64 -> "x86_64" SystemArchitecture.AARCH64 -> "aarch64" SystemArchitecture.X86 -> "i386" SystemArchitecture.ARM -> "arm" SystemArchitecture.UNKNOWN, null -> null } fun isExactArchitectureMatch( assetName: String, systemArch: SystemArchitecture, ): Boolean = AssetArchitectureMatcher.isExactMatch(assetName, systemArch) ================================================ FILE: feature/dev-profile/CLAUDE.md ================================================ # CLAUDE.md - Developer Profile Feature ## Purpose Displays a GitHub developer/user profile. Shows user info (avatar, bio, stats), their repositories with filtering and sorting, and follower/following counts. Reached by clicking on a developer's name from any repository card. ## Module Structure ``` feature/dev-profile/ ├── domain/ │ ├── model/ │ │ ├── DeveloperProfile.kt # User profile data model │ │ ├── DeveloperRepository.kt # User's repository model │ │ ├── RepoFilterType.kt # Filter: All, Sources, Forks, etc. │ │ └── RepoSortType.kt # Sort: Stars, Name, Updated, etc. │ └── repository/DeveloperProfileRepository.kt # Profile + repos ├── data/ │ ├── di/SharedModule.kt # Koin: devProfileModule │ ├── repository/DeveloperProfileRepositoryImpl.kt │ ├── dto/ # Network DTOs │ └── mappers/ # DTO → domain model mappers └── presentation/ ├── DeveloperProfileViewModel.kt # Profile loading, repo filtering/sorting ├── DeveloperProfileState.kt # profile, repos, filters, loading ├── DeveloperProfileAction.kt # Load, filter, sort, click actions ├── DeveloperProfileEvent.kt # One-off events ├── DeveloperProfileRoot.kt # Main composable └── components/ # Profile header, repo list, filter controls ``` ## Key Interfaces ```kotlin interface DeveloperProfileRepository { suspend fun getDeveloperProfile(username: String): Result suspend fun getDeveloperRepositories(username: String): Result> } ``` ## Navigation Route: `GithubStoreGraph.DeveloperProfileScreen(username: String)` ## Implementation Notes - Profile and repos are fetched in parallel on load - Client-side filtering by `RepoFilterType` (All, Sources, Forks) and sorting by `RepoSortType` (Stars, Name, Updated) - Both API calls return `Result` for error handling - Reached from repository cards throughout the app (home, search, details, favourites, starred) ================================================ FILE: feature/dev-profile/data/.gitignore ================================================ /build ================================================ FILE: feature/dev-profile/data/build.gradle.kts ================================================ plugins { alias(libs.plugins.convention.kmp.library) alias(libs.plugins.convention.buildkonfig) } kotlin { sourceSets { commonMain { dependencies { implementation(libs.kotlin.stdlib) implementation(projects.core.domain) implementation(projects.core.data) implementation(projects.feature.devProfile.domain) implementation(libs.kotlinx.coroutines.core) implementation(libs.bundles.ktor.common) implementation(libs.bundles.koin.common) } } androidMain { dependencies { } } jvmMain { dependencies { } } } } ================================================ FILE: feature/dev-profile/data/src/androidMain/AndroidManifest.xml ================================================ ================================================ FILE: feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/di/SharedModule.kt ================================================ package zed.rainxch.devprofile.data.di import org.koin.dsl.module import zed.rainxch.devprofile.data.repository.DeveloperProfileRepositoryImpl import zed.rainxch.devprofile.domain.repository.DeveloperProfileRepository val devProfileModule = module { single { DeveloperProfileRepositoryImpl( logger = get(), httpClient = get(), platform = get(), installedAppsDao = get(), favouritesRepository = get(), ) } } ================================================ FILE: feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/dto/GitHubRepoResponse.kt ================================================ package zed.rainxch.devprofile.data.dto import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class GitHubRepoResponse( val id: Long, val name: String, @SerialName("full_name") val fullName: String, val description: String? = null, @SerialName("html_url") val htmlUrl: String, @SerialName("stargazers_count") val stargazersCount: Int, @SerialName("forks_count") val forksCount: Int, @SerialName("open_issues_count") val openIssuesCount: Int, val language: String? = null, @SerialName("updated_at") val updatedAt: String, @SerialName("pushed_at") val pushedAt: String? = null, @SerialName("has_downloads") val hasDownloads: Boolean = false, val archived: Boolean = false, val fork: Boolean = false, ) ================================================ FILE: feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/dto/GitHubUserResponse.kt ================================================ package zed.rainxch.devprofile.data.dto import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class GitHubUserResponse( val login: String, val name: String? = null, @SerialName("avatar_url") val avatarUrl: String, val bio: String? = null, val company: String? = null, val location: String? = null, val email: String? = null, val blog: String? = null, @SerialName("twitter_username") val twitterUsername: String? = null, @SerialName("public_repos") val publicRepos: Int, @SerialName("public_gists") val publicGists: Int, val followers: Int, val following: Int, @SerialName("created_at") val createdAt: String, @SerialName("updated_at") val updatedAt: String, @SerialName("html_url") val htmlUrl: String, ) ================================================ FILE: feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/mappers/GitHubRepoToDomain.kt ================================================ package zed.rainxch.devprofile.data.mappers import zed.rainxch.devprofile.data.dto.GitHubRepoResponse import zed.rainxch.devprofile.domain.model.DeveloperRepository fun GitHubRepoResponse.toDomain( hasReleases: Boolean = false, hasInstallableAssets: Boolean = false, isInstalled: Boolean = false, isFavorite: Boolean = false, latestVersion: String? = null, ) = DeveloperRepository( id = id, name = name, fullName = fullName, description = description, htmlUrl = htmlUrl, stargazersCount = stargazersCount, forksCount = forksCount, openIssuesCount = openIssuesCount, language = language, hasReleases = hasReleases, hasInstallableAssets = hasInstallableAssets, isInstalled = isInstalled, isFavorite = isFavorite, latestVersion = latestVersion, updatedAt = updatedAt, pushedAt = pushedAt, ) ================================================ FILE: feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/mappers/GitHubUserToDomain.kt ================================================ package zed.rainxch.devprofile.data.mappers import zed.rainxch.devprofile.data.dto.GitHubUserResponse import zed.rainxch.devprofile.domain.model.DeveloperProfile fun GitHubUserResponse.toDomain() = DeveloperProfile( login = login, name = name, avatarUrl = avatarUrl, bio = bio, company = company, location = location, email = email, blog = blog, twitterUsername = twitterUsername, publicRepos = publicRepos, publicGists = publicGists, followers = followers, following = following, createdAt = createdAt, updatedAt = updatedAt, htmlUrl = htmlUrl, ) ================================================ FILE: feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/repository/DeveloperProfileRepositoryImpl.kt ================================================ package zed.rainxch.devprofile.data.repository import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.request.get import io.ktor.client.request.parameter import io.ktor.http.isSuccess import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.first import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import zed.rainxch.core.data.local.db.dao.InstalledAppDao import zed.rainxch.core.domain.logging.GitHubStoreLogger import zed.rainxch.core.domain.model.Platform import zed.rainxch.core.domain.model.RateLimitException import zed.rainxch.core.domain.repository.FavouritesRepository import zed.rainxch.devprofile.data.dto.GitHubRepoResponse import zed.rainxch.devprofile.data.dto.GitHubUserResponse import zed.rainxch.devprofile.data.mappers.toDomain import zed.rainxch.devprofile.domain.model.DeveloperProfile import zed.rainxch.devprofile.domain.model.DeveloperRepository import zed.rainxch.devprofile.domain.repository.DeveloperProfileRepository class DeveloperProfileRepositoryImpl( private val httpClient: HttpClient, private val platform: Platform, private val installedAppsDao: InstalledAppDao, private val favouritesRepository: FavouritesRepository, private val logger: GitHubStoreLogger, ) : DeveloperProfileRepository { override suspend fun getDeveloperProfile(username: String): Result { return withContext(Dispatchers.IO) { try { val response = httpClient.get("/users/$username") if (!response.status.isSuccess()) { return@withContext Result.failure( Exception("Failed to fetch developer profile: ${response.status.description}"), ) } val userResponse: GitHubUserResponse = response.body() Result.success(userResponse.toDomain()) } catch (e: RateLimitException) { throw e } catch (e: CancellationException) { throw e } catch (e: Exception) { logger.error("Failed to fetch developer profile for $username") Result.failure(e) } } } override suspend fun getDeveloperRepositories(username: String): Result> { return withContext(Dispatchers.IO) { try { val allRepos = mutableListOf() var page = 1 val perPage = 100 while (true) { val response = httpClient.get("/users/$username/repos") { parameter("per_page", perPage) parameter("page", page) parameter("type", "owner") parameter("sort", "updated") parameter("direction", "desc") } if (!response.status.isSuccess()) { return@withContext Result.failure( Exception("Failed to fetch repositories: ${response.status.description}"), ) } val repos: List = response.body() if (repos.isEmpty()) break allRepos.addAll(repos.filter { !it.archived && !it.fork }) if (repos.size < perPage) break page++ } val allFavorites = favouritesRepository.getAllFavorites().first() val favoriteIds = allFavorites.map { it.repoId }.toSet() val processedRepos = coroutineScope { val semaphore = Semaphore(20) val deferredResults = allRepos.map { repo -> async { semaphore.withPermit { processRepository(repo, favoriteIds) } } } deferredResults.awaitAll() } Result.success(processedRepos) } catch (e: RateLimitException) { throw e } catch (e: CancellationException) { throw e } catch (e: Exception) { logger.error("Failed to fetch repositories for $username") Result.failure(e) } } } private suspend fun processRepository( repo: GitHubRepoResponse, favoriteIds: Set, ): DeveloperRepository { val installedApp = installedAppsDao.getAppByRepoId(repo.id) val isFavorite = favoriteIds.contains(repo.id) val (hasReleases, hasInstallableAssets, latestVersion) = checkReleaseInfo( owner = repo.fullName.split("/")[0], repoName = repo.name, ) return repo.toDomain( hasReleases = hasReleases, hasInstallableAssets = hasInstallableAssets, isInstalled = installedApp != null, isFavorite = isFavorite, latestVersion = latestVersion, ) } private suspend fun checkReleaseInfo( owner: String, repoName: String, ): Triple { return try { val response = httpClient.get("/repos/$owner/$repoName/releases") { parameter("per_page", 10) } if (!response.status.isSuccess()) { return Triple(false, false, null) } val releases: List = response.body() val stableRelease = releases.firstOrNull { it.draft != true && it.prerelease != true } if (stableRelease == null) { return Triple(releases.isNotEmpty(), false, null) } val hasInstallableAssets = stableRelease.assets.any { asset -> val name = asset.name.lowercase() when (platform) { Platform.ANDROID -> { name.endsWith(".apk") } Platform.WINDOWS -> { name.endsWith(".msi") || name.endsWith(".exe") } Platform.MACOS -> { name.endsWith(".dmg") || name.endsWith(".pkg") } Platform.LINUX -> { name.endsWith(".appimage") || name.endsWith(".deb") || name.endsWith(".rpm") } } } Triple( true, hasInstallableAssets, if (hasInstallableAssets) stableRelease.tagName else null, ) } catch (e: RateLimitException) { throw e } catch (e: CancellationException) { throw e } catch (e: Exception) { logger.warn("Failed to check releases for $owner/$repoName : ${e.message}") Triple(false, false, null) } } @Serializable private data class ReleaseNetworkModel( val assets: List, val draft: Boolean? = null, val prerelease: Boolean? = null, @SerialName("tag_name") val tagName: String, @SerialName("published_at") val publishedAt: String? = null, ) @Serializable private data class AssetNetworkModel( val name: String, ) } ================================================ FILE: feature/dev-profile/domain/.gitignore ================================================ /build ================================================ FILE: feature/dev-profile/domain/build.gradle.kts ================================================ plugins { alias(libs.plugins.convention.kmp.library) } kotlin { sourceSets { commonMain { dependencies { implementation(libs.kotlin.stdlib) implementation(projects.core.domain) } } androidMain { dependencies { } } jvmMain { dependencies { } } } } ================================================ FILE: feature/dev-profile/domain/src/androidMain/AndroidManifest.xml ================================================ ================================================ FILE: feature/dev-profile/domain/src/commonMain/kotlin/zed/rainxch/devprofile/domain/model/DeveloperProfile.kt ================================================ package zed.rainxch.devprofile.domain.model data class DeveloperProfile( val login: String, val name: String?, val avatarUrl: String, val bio: String?, val company: String?, val location: String?, val email: String?, val blog: String?, val twitterUsername: String?, val publicRepos: Int, val publicGists: Int, val followers: Int, val following: Int, val createdAt: String, val updatedAt: String, val htmlUrl: String, ) ================================================ FILE: feature/dev-profile/domain/src/commonMain/kotlin/zed/rainxch/devprofile/domain/model/DeveloperRepository.kt ================================================ package zed.rainxch.devprofile.domain.model data class DeveloperRepository( val id: Long, val name: String, val fullName: String, val description: String?, val htmlUrl: String, val stargazersCount: Int, val forksCount: Int, val openIssuesCount: Int, val language: String?, val hasReleases: Boolean, val hasInstallableAssets: Boolean, val isInstalled: Boolean = false, val isFavorite: Boolean = false, val latestVersion: String? = null, val updatedAt: String, val pushedAt: String?, ) ================================================ FILE: feature/dev-profile/domain/src/commonMain/kotlin/zed/rainxch/devprofile/domain/model/RepoFilterType.kt ================================================ package zed.rainxch.devprofile.domain.model enum class RepoFilterType { WITH_RELEASES, INSTALLED, FAVORITES, } ================================================ FILE: feature/dev-profile/domain/src/commonMain/kotlin/zed/rainxch/devprofile/domain/model/RepoSortType.kt ================================================ package zed.rainxch.devprofile.domain.model enum class RepoSortType { UPDATED, STARS, NAME, } ================================================ FILE: feature/dev-profile/domain/src/commonMain/kotlin/zed/rainxch/devprofile/domain/repository/DeveloperProfileRepository.kt ================================================ package zed.rainxch.devprofile.domain.repository import zed.rainxch.devprofile.domain.model.DeveloperProfile import zed.rainxch.devprofile.domain.model.DeveloperRepository interface DeveloperProfileRepository { suspend fun getDeveloperProfile(username: String): Result suspend fun getDeveloperRepositories(username: String): Result> } ================================================ FILE: feature/dev-profile/presentation/.gitignore ================================================ /build ================================================ FILE: feature/dev-profile/presentation/build.gradle.kts ================================================ plugins { alias(libs.plugins.convention.cmp.feature) } kotlin { sourceSets { commonMain { dependencies { implementation(libs.kotlin.stdlib) implementation(projects.core.domain) implementation(projects.core.presentation) implementation(projects.feature.devProfile.domain) implementation(libs.androidx.compose.ui.tooling.preview) implementation(compose.components.resources) implementation(libs.bundles.landscapist) implementation(libs.kotlinx.collections.immutable) } } androidMain { dependencies { } } jvmMain { dependencies { } } } } ================================================ FILE: feature/dev-profile/presentation/src/androidMain/AndroidManifest.xml ================================================ ================================================ FILE: feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/DeveloperProfileAction.kt ================================================ package zed.rainxch.devprofile.presentation import zed.rainxch.devprofile.domain.model.DeveloperRepository import zed.rainxch.devprofile.domain.model.RepoFilterType import zed.rainxch.devprofile.domain.model.RepoSortType sealed interface DeveloperProfileAction { data object OnNavigateBackClick : DeveloperProfileAction data class OnRepositoryClick( val repoId: Long, ) : DeveloperProfileAction data class OnFilterChange( val filter: RepoFilterType, ) : DeveloperProfileAction data class OnSortChange( val sort: RepoSortType, ) : DeveloperProfileAction data class OnSearchQueryChange( val query: String, ) : DeveloperProfileAction data class OnToggleFavorite( val repository: DeveloperRepository, ) : DeveloperProfileAction data object OnDismissError : DeveloperProfileAction data object OnRetry : DeveloperProfileAction data class OnOpenLink( val url: String, ) : DeveloperProfileAction } ================================================ FILE: feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/DeveloperProfileRoot.kt ================================================ package zed.rainxch.devprofile.presentation 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.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Error import androidx.compose.material.icons.filled.FolderOff import androidx.compose.material.icons.filled.OpenInBrowser import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Snackbar import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import zed.rainxch.core.presentation.components.GithubStoreButton import zed.rainxch.devprofile.domain.model.RepoFilterType import zed.rainxch.devprofile.presentation.components.DeveloperRepoItem import zed.rainxch.devprofile.presentation.components.FilterSortControls import zed.rainxch.devprofile.presentation.components.ProfileInfoCard import zed.rainxch.devprofile.presentation.components.StatsRow import zed.rainxch.githubstore.core.presentation.res.* @Composable fun DeveloperProfileRoot( onNavigateBack: () -> Unit, onNavigateToDetails: (repoId: Long) -> Unit, viewModel: DeveloperProfileViewModel = koinViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() val uriHandler = LocalUriHandler.current DeveloperProfileScreen( state = state, onAction = { action -> when (action) { DeveloperProfileAction.OnNavigateBackClick -> { onNavigateBack() } is DeveloperProfileAction.OnRepositoryClick -> { onNavigateToDetails(action.repoId) } is DeveloperProfileAction.OnOpenLink -> { val url = action.url.trim() val allowed = url.startsWith("https://") || url.startsWith("http://") if (allowed) uriHandler.openUri(url) } else -> { viewModel.onAction(action) } } }, ) } @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun DeveloperProfileScreen( state: DeveloperProfileState, onAction: (DeveloperProfileAction) -> Unit, ) { Scaffold( topBar = { DevProfileTopbar( state = state, onAction = onAction, ) }, containerColor = MaterialTheme.colorScheme.background, ) { innerPadding -> Box( modifier = Modifier .fillMaxSize() .padding(innerPadding), ) { when { state.isLoading -> { CircularWavyProgressIndicator( modifier = Modifier.align(Alignment.Center), ) } state.errorMessage != null && state.profile == null -> { ErrorContent( message = state.errorMessage, onRetry = { onAction(DeveloperProfileAction.OnRetry) }, modifier = Modifier.align(Alignment.Center), ) } state.profile != null -> { LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { item { ProfileInfoCard( profile = state.profile, onAction = onAction, ) } item { StatsRow(profile = state.profile) } item { FilterSortControls( currentFilter = state.currentFilter, currentSort = state.currentSort, searchQuery = state.searchQuery, repoCount = state.filteredRepositories.size, totalCount = state.repositories.size, onAction = onAction, ) } if (state.isLoadingRepos) { item { Box( modifier = Modifier .fillMaxWidth() .padding(32.dp), contentAlignment = Alignment.Center, ) { CircularWavyProgressIndicator() } } } else if (state.filteredRepositories.isEmpty()) { item { EmptyReposContent( filter = state.currentFilter, modifier = Modifier .fillMaxWidth() .padding(32.dp), ) } } else { items( items = state.filteredRepositories, key = { it.id }, ) { repo -> DeveloperRepoItem( repository = repo, onItemClick = { onAction(DeveloperProfileAction.OnRepositoryClick(repo.id)) }, onToggleFavorite = { onAction(DeveloperProfileAction.OnToggleFavorite(repo)) }, modifier = Modifier.animateItem(), ) } } } } } if (state.errorMessage != null && state.profile != null) { Snackbar( modifier = Modifier .align(Alignment.BottomCenter) .padding(16.dp), action = { TextButton( onClick = { onAction(DeveloperProfileAction.OnRetry) }, ) { Text( text = stringResource(Res.string.retry), ) } }, dismissAction = { IconButton( onClick = { onAction(DeveloperProfileAction.OnDismissError) }, ) { Icon( imageVector = Icons.Default.Close, contentDescription = stringResource(Res.string.dismiss), ) } }, ) { Text( text = state.errorMessage, ) } } } } } @Composable private fun EmptyReposContent( filter: RepoFilterType, modifier: Modifier = Modifier, ) { val message = when (filter) { RepoFilterType.WITH_RELEASES -> stringResource(Res.string.no_repos_with_releases) RepoFilterType.INSTALLED -> stringResource(Res.string.no_installed_repos) RepoFilterType.FAVORITES -> stringResource(Res.string.no_favorite_repos) } Column( modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { Icon( imageVector = Icons.Default.FolderOff, contentDescription = null, modifier = Modifier.size(48.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), ) Spacer(modifier = Modifier.height(12.dp)) Text( text = message, maxLines = 2, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, ) } } @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun DevProfileTopbar( state: DeveloperProfileState, onAction: (DeveloperProfileAction) -> Unit, ) { TopAppBar( navigationIcon = { IconButton( shapes = IconButtonDefaults.shapes(), onClick = { onAction(DeveloperProfileAction.OnNavigateBackClick) }, ) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.navigate_back), modifier = Modifier.size(24.dp), ) } }, title = { Text( text = state.username, style = MaterialTheme.typography.titleMediumEmphasized, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface, ) }, actions = { state.profile?.htmlUrl?.let { IconButton( shapes = IconButtonDefaults.shapes(), onClick = { onAction(DeveloperProfileAction.OnOpenLink(it)) }, colors = IconButtonDefaults.iconButtonColors( contentColor = MaterialTheme.colorScheme.onSurface, ), ) { Icon( imageVector = Icons.Default.OpenInBrowser, contentDescription = stringResource(Res.string.open_repository), modifier = Modifier.size(24.dp), ) } } }, ) } @Composable private fun ErrorContent( message: String, onRetry: () -> Unit, modifier: Modifier = Modifier, ) { Column( modifier = modifier.padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { Icon( imageVector = Icons.Default.Error, contentDescription = null, modifier = Modifier.size(64.dp), tint = MaterialTheme.colorScheme.error, ) Spacer(modifier = Modifier.height(16.dp)) Text( text = stringResource(Res.string.error_generic, message), maxLines = 3, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, ) Spacer(modifier = Modifier.height(16.dp)) GithubStoreButton( text = stringResource(Res.string.retry), onClick = { onRetry() }, ) } } ================================================ FILE: feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/DeveloperProfileState.kt ================================================ package zed.rainxch.devprofile.presentation import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import zed.rainxch.devprofile.domain.model.DeveloperProfile import zed.rainxch.devprofile.domain.model.DeveloperRepository import zed.rainxch.devprofile.domain.model.RepoFilterType import zed.rainxch.devprofile.domain.model.RepoSortType data class DeveloperProfileState( val username: String = "", val profile: DeveloperProfile? = null, val repositories: ImmutableList = persistentListOf(), val filteredRepositories: ImmutableList = persistentListOf(), val isLoading: Boolean = false, val isLoadingRepos: Boolean = false, val errorMessage: String? = null, val currentFilter: RepoFilterType = RepoFilterType.WITH_RELEASES, val currentSort: RepoSortType = RepoSortType.UPDATED, val searchQuery: String = "", ) ================================================ FILE: feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/DeveloperProfileViewModel.kt ================================================ @file:OptIn(ExperimentalTime::class) package zed.rainxch.devprofile.presentation import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.jetbrains.compose.resources.getString import zed.rainxch.core.domain.model.FavoriteRepo import zed.rainxch.core.domain.model.RateLimitException import zed.rainxch.core.domain.repository.FavouritesRepository import zed.rainxch.devprofile.domain.model.RepoFilterType import zed.rainxch.devprofile.domain.model.RepoSortType import zed.rainxch.devprofile.domain.repository.DeveloperProfileRepository import zed.rainxch.githubstore.core.presentation.res.* import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Clock import kotlin.time.ExperimentalTime class DeveloperProfileViewModel( private val username: String, private val repository: DeveloperProfileRepository, private val favouritesRepository: FavouritesRepository, ) : ViewModel() { private var hasLoadedInitialData = false private val _state = MutableStateFlow(DeveloperProfileState(username = username)) val state = _state .onStart { if (!hasLoadedInitialData) { loadDeveloperData() hasLoadedInitialData = true } }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000L), initialValue = DeveloperProfileState(username = username), ) private fun loadDeveloperData() { viewModelScope.launch { try { _state.update { it.copy( isLoading = true, isLoadingRepos = false, errorMessage = null, ) } val profileResult = repository.getDeveloperProfile(username) profileResult .onSuccess { profile -> _state.update { it.copy( profile = profile, isLoading = false, isLoadingRepos = true, ) } }.onFailure { error -> _state.update { it.copy( isLoading = false, errorMessage = error.message ?: getString(Res.string.failed_to_load_profile), ) } return@launch } val reposResult = repository.getDeveloperRepositories(username) reposResult .onSuccess { repos -> _state.update { it.copy( repositories = repos.toImmutableList(), isLoading = false, isLoadingRepos = false, ) } applyFiltersAndSort() }.onFailure { error -> _state.update { it.copy( isLoading = false, isLoadingRepos = false, errorMessage = error.message ?: getString(Res.string.failed_to_load_repositories), ) } } } catch (_: RateLimitException) { _state.update { it.copy(isLoading = false, isLoadingRepos = false) } } catch (e: CancellationException) { throw e } catch (e: Exception) { _state.update { it.copy( isLoading = false, isLoadingRepos = false, errorMessage = e.message, ) } } } } private fun applyFiltersAndSort() { viewModelScope.launch(Dispatchers.Default) { val currentState = _state.value var filtered = currentState.repositories if (currentState.searchQuery.isNotBlank()) { val query = currentState.searchQuery.lowercase() filtered = filtered .filter { repo -> repo.name.lowercase().contains(query) || repo.description?.lowercase()?.contains(query) == true }.toImmutableList() } filtered = when (currentState.currentFilter) { RepoFilterType.WITH_RELEASES -> { filtered .filter { it.hasInstallableAssets } .toImmutableList() } RepoFilterType.INSTALLED -> { filtered.filter { it.isInstalled }.toImmutableList() } RepoFilterType.FAVORITES -> { filtered.filter { it.isFavorite }.toImmutableList() } } filtered = when (currentState.currentSort) { RepoSortType.UPDATED -> { filtered .sortedByDescending { it.updatedAt } .toImmutableList() } RepoSortType.STARS -> { filtered .sortedByDescending { it.stargazersCount } .toImmutableList() } RepoSortType.NAME -> { filtered.sortedBy { it.name.lowercase() }.toImmutableList() } } _state.update { it.copy(filteredRepositories = filtered) } } } fun onAction(action: DeveloperProfileAction) { when (action) { DeveloperProfileAction.OnNavigateBackClick, is DeveloperProfileAction.OnRepositoryClick, is DeveloperProfileAction.OnOpenLink, -> { } is DeveloperProfileAction.OnFilterChange -> { _state.update { it.copy(currentFilter = action.filter) } applyFiltersAndSort() } is DeveloperProfileAction.OnSortChange -> { _state.update { it.copy(currentSort = action.sort) } applyFiltersAndSort() } is DeveloperProfileAction.OnSearchQueryChange -> { _state.update { it.copy(searchQuery = action.query) } applyFiltersAndSort() } is DeveloperProfileAction.OnToggleFavorite -> { viewModelScope.launch { val repo = action.repository val favoriteRepo = FavoriteRepo( repoId = repo.id, repoName = repo.name, repoOwner = repo.fullName.split("/")[0], repoOwnerAvatarUrl = _state.value.profile?.avatarUrl ?: "", repoDescription = repo.description, primaryLanguage = repo.language, repoUrl = repo.htmlUrl, latestVersion = repo.latestVersion, latestReleaseUrl = null, addedAt = Clock.System.now().toEpochMilliseconds(), lastSyncedAt = Clock.System.now().toEpochMilliseconds(), ) favouritesRepository.toggleFavorite(favoriteRepo) _state.update { state -> val updatedRepos = state.repositories .map { if (it.id == repo.id) { it.copy(isFavorite = !it.isFavorite) } else { it } }.toImmutableList() state.copy(repositories = updatedRepos) } applyFiltersAndSort() } } DeveloperProfileAction.OnDismissError -> { _state.update { it.copy(errorMessage = null) } } DeveloperProfileAction.OnRetry -> { loadDeveloperData() } } } } ================================================ FILE: feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/components/DeveloperRepoItem.kt ================================================ @file:OptIn(ExperimentalTime::class) package zed.rainxch.devprofile.presentation.components import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column 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.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.CallSplit import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.outlined.FavoriteBorder import androidx.compose.material.icons.outlined.Warning import androidx.compose.material3.Badge import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilledIconToggleButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialShapes import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SuggestionChip import androidx.compose.material3.Text import androidx.compose.material3.toShape 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.graphics.vector.ImageVector import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import zed.rainxch.core.presentation.components.ExpressiveCard import zed.rainxch.core.presentation.theme.GithubStoreTheme import zed.rainxch.core.presentation.utils.formatCount import zed.rainxch.devprofile.domain.model.DeveloperRepository import zed.rainxch.githubstore.core.presentation.res.* import kotlin.time.Clock import kotlin.time.ExperimentalTime import kotlin.time.Instant @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun DeveloperRepoItem( repository: DeveloperRepository, onItemClick: () -> Unit, onToggleFavorite: () -> Unit, modifier: Modifier = Modifier, ) { ExpressiveCard( onClick = onItemClick, modifier = modifier.fillMaxWidth(), ) { Column( modifier = Modifier .fillMaxWidth() .padding(16.dp), ) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f)) { Text( text = repository.name, maxLines = 1, style = MaterialTheme.typography.titleLarge, overflow = TextOverflow.Ellipsis, color = MaterialTheme.colorScheme.onSurface, ) Text( text = stringResource( resource = Res.string.updated_on_date, formatRelativeDate(repository.updatedAt), ).replaceFirstChar { it.uppercase() }, style = MaterialTheme.typography.titleMedium, overflow = TextOverflow.Ellipsis, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, ) } Spacer(modifier = Modifier.width(8.dp)) FilledIconToggleButton( checked = repository.isFavorite, onCheckedChange = { onToggleFavorite() }, modifier = Modifier.size(40.dp), shape = MaterialShapes.Cookie6Sided.toShape(), ) { Icon( imageVector = if (repository.isFavorite) { Icons.Filled.Favorite } else { Icons.Outlined.FavoriteBorder }, contentDescription = if (repository.isFavorite) { stringResource(Res.string.remove_from_favourites) } else { stringResource(Res.string.add_to_favourites) }, modifier = Modifier.size(20.dp), ) } } repository.description?.let { description -> Spacer(modifier = Modifier.height(8.dp)) Text( text = description, maxLines = 2, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } Spacer(modifier = Modifier.height(12.dp)) Row( modifier = Modifier .fillMaxWidth() .horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically, ) { RepoStat( icon = Icons.Default.Star, value = formatCount(repository.stargazersCount), contentDescription = $$"$${repository.stargazersCount} $${stringResource(Res.string.stars)}", ) RepoStat( icon = Icons.AutoMirrored.Filled.CallSplit, value = formatCount(repository.forksCount), contentDescription = "${repository.forksCount} ${stringResource(Res.string.forks)}", ) if (repository.openIssuesCount > 0) { RepoStat( icon = Icons.Outlined.Warning, value = formatCount(repository.openIssuesCount), contentDescription = "${repository.openIssuesCount} ${stringResource(Res.string.issues)}", ) } repository.language?.let { language -> SuggestionChip( onClick = {}, label = { Text( text = language, style = MaterialTheme.typography.labelSmall, ) }, modifier = Modifier.height(32.dp), ) } } val repoBadges = buildList { if (repository.hasInstallableAssets) { add( RepoBadge( text = repository.latestVersion ?: stringResource(Res.string.has_release), containerColor = MaterialTheme.colorScheme.primaryContainer, contentColor = MaterialTheme.colorScheme.onPrimaryContainer, ), ) } if (repository.isInstalled) { add( RepoBadge( text = stringResource(Res.string.installed), containerColor = MaterialTheme.colorScheme.tertiaryContainer, contentColor = MaterialTheme.colorScheme.onTertiaryContainer, ), ) } } if (repoBadges.isNotEmpty()) { Spacer(modifier = Modifier.height(12.dp)) FlowRow( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { repoBadges.forEach { badge -> Badge( containerColor = badge.containerColor, ) { Text( text = badge.text, style = MaterialTheme.typography.labelSmall, color = badge.contentColor, ) } } } } } } } @Composable private fun RepoStat( icon: ImageVector, value: String, contentDescription: String, modifier: Modifier = Modifier, ) { Row( modifier = modifier, horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( text = value, maxLines = 1, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } private data class RepoBadge( val text: String, val containerColor: Color, val contentColor: Color, ) @Composable private fun formatRelativeDate(dateString: String): String { val instant = try { Instant.parse(dateString) } catch (_: IllegalArgumentException) { return dateString } val now = Clock.System.now() val duration = now - instant return when { duration.inWholeDays > 365 -> { stringResource( Res.string.time_years_ago, (duration.inWholeDays / 365).toInt(), ) } duration.inWholeDays > 30 -> { stringResource( Res.string.time_months_ago, (duration.inWholeDays / 30).toInt(), ) } duration.inWholeDays > 0 -> { stringResource( Res.string.time_days_ago, duration.inWholeDays.toInt(), ) } duration.inWholeHours > 0 -> { stringResource( Res.string.time_hours_ago, duration.inWholeHours.toInt(), ) } duration.inWholeMinutes > 0 -> { stringResource( Res.string.time_minutes_ago, duration.inWholeMinutes.toInt(), ) } else -> { stringResource(Res.string.just_now) } } } @Preview @Composable private fun PreviewDeveloperRepoItem() { GithubStoreTheme { DeveloperRepoItem( repository = DeveloperRepository( id = 1, name = "awesome-kotlin-app", fullName = "developer/awesome-kotlin-app", description = "An amazing Kotlin Multiplatform application that demonstrates modern Android development", htmlUrl = "", stargazersCount = 2340, forksCount = 456, openIssuesCount = 23, language = "Kotlin", hasReleases = true, hasInstallableAssets = true, isInstalled = true, isFavorite = false, latestVersion = "v1.5.2", updatedAt = Clock.System.now().toString(), pushedAt = null, ), onItemClick = {}, onToggleFavorite = {}, ) } } ================================================ FILE: feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/components/FilterSortControls.kt ================================================ package zed.rainxch.devprofile.presentation.components 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.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Sort import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Search import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialShapes import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SecondaryScrollableTabRow import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.toShape 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.unit.dp import org.jetbrains.compose.resources.stringResource import zed.rainxch.devprofile.domain.model.RepoFilterType import zed.rainxch.devprofile.domain.model.RepoSortType import zed.rainxch.devprofile.presentation.DeveloperProfileAction import zed.rainxch.githubstore.core.presentation.res.* @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun FilterSortControls( currentFilter: RepoFilterType, currentSort: RepoSortType, searchQuery: String, repoCount: Int, totalCount: Int, onAction: (DeveloperProfileAction) -> Unit, ) { Column( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp), ) { OutlinedTextField( value = searchQuery, onValueChange = { query -> onAction(DeveloperProfileAction.OnSearchQueryChange(query)) }, modifier = Modifier.fillMaxWidth(), placeholder = { Text( text = stringResource(Res.string.search_repositories), maxLines = 1, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) }, leadingIcon = { Icon( imageVector = Icons.Default.Search, contentDescription = stringResource(Res.string.search_repositories), modifier = Modifier.size(20.dp), ) }, trailingIcon = { if (searchQuery.isNotBlank()) { IconButton( onClick = { onAction(DeveloperProfileAction.OnSearchQueryChange("")) }, ) { Icon( imageVector = Icons.Default.Close, contentDescription = stringResource(Res.string.clear_search), modifier = Modifier.size(20.dp), ) } } }, singleLine = true, shape = RoundedCornerShape(12.dp), ) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { SecondaryScrollableTabRow( selectedTabIndex = currentFilter.ordinal, modifier = Modifier.weight(1f), edgePadding = 0.dp, divider = {}, ) { RepoFilterType.entries.forEach { filter -> FilterChipTab( selected = currentFilter == filter, onClick = { onAction(DeveloperProfileAction.OnFilterChange(filter)) }, label = filter.displayName(), ) } } SortMenu( currentSort = currentSort, onSortChange = { sort -> onAction(DeveloperProfileAction.OnSortChange(sort)) }, ) } Text( text = if (repoCount == totalCount) { "$repoCount ${ stringResource( if (repoCount == 1) { Res.string.repository_singular } else { Res.string.repositories }, ) }" } else { stringResource( resource = Res.string.showing_x_of_y_repositories, repoCount, totalCount, ) }, maxLines = 1, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } @Composable private fun FilterChipTab( selected: Boolean, onClick: () -> Unit, label: String, ) { Tab( selected = selected, onClick = onClick, modifier = Modifier.height(40.dp), ) { Text( text = label, style = MaterialTheme.typography.labelMedium, color = if (selected) { MaterialTheme.colorScheme.primary } else { MaterialTheme.colorScheme.onSurfaceVariant }, ) } } @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun SortMenu( currentSort: RepoSortType, onSortChange: (RepoSortType) -> Unit, ) { var expanded by remember { mutableStateOf(false) } Box { FilledIconButton( onClick = { expanded = true }, modifier = Modifier.size(40.dp), shape = MaterialShapes.Cookie9Sided.toShape(), ) { Icon( imageVector = Icons.AutoMirrored.Filled.Sort, contentDescription = stringResource(Res.string.sort), modifier = Modifier.size(20.dp), ) } DropdownMenu( expanded = expanded, onDismissRequest = { expanded = false }, shape = RoundedCornerShape(32.dp), ) { RepoSortType.entries.forEach { sort -> DropdownMenuItem( text = { Row( modifier = Modifier.padding(4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { if (currentSort == sort) { Icon( imageVector = Icons.Default.Check, contentDescription = null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.primary, ) } else { Spacer(modifier = Modifier.size(18.dp)) } Text( text = sort.displayName(), maxLines = 1, style = MaterialTheme.typography.bodyMedium, color = if (currentSort == sort) { MaterialTheme.colorScheme.primary } else { MaterialTheme.colorScheme.onSurface }, ) } }, onClick = { onSortChange(sort) expanded = false }, ) } } } } @Composable private fun RepoFilterType.displayName(): String = when (this) { RepoFilterType.WITH_RELEASES -> stringResource(Res.string.filter_with_releases) RepoFilterType.INSTALLED -> stringResource(Res.string.filter_installed) RepoFilterType.FAVORITES -> stringResource(Res.string.filter_favorites) } @Composable private fun RepoSortType.displayName(): String = when (this) { RepoSortType.UPDATED -> stringResource(Res.string.sort_recently_updated) RepoSortType.STARS -> stringResource(Res.string.sort_most_stars) RepoSortType.NAME -> stringResource(Res.string.sort_name) } ================================================ FILE: feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/components/ProfileInfoCard.kt ================================================ package zed.rainxch.devprofile.presentation.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column 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.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Business import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.LocationOn import androidx.compose.material.icons.filled.Tag import androidx.compose.material3.AssistChip import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text 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.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.skydoves.landscapist.ImageOptions import com.skydoves.landscapist.coil3.CoilImage import zed.rainxch.core.presentation.components.ExpressiveCard import zed.rainxch.devprofile.domain.model.DeveloperProfile import zed.rainxch.devprofile.presentation.DeveloperProfileAction @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun ProfileInfoCard( profile: DeveloperProfile, onAction: (DeveloperProfileAction) -> Unit, ) { ExpressiveCard { Column( modifier = Modifier .fillMaxWidth() .padding(16.dp), ) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top, ) { CoilImage( imageModel = { profile.avatarUrl }, modifier = Modifier .size(80.dp) .clip(CircleShape), imageOptions = ImageOptions( contentScale = ContentScale.Crop, ), ) Spacer(modifier = Modifier.width(16.dp)) Column(modifier = Modifier.weight(1f)) { Text( text = profile.name ?: profile.login, maxLines = 2, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, ) Spacer(Modifier.height(4.dp)) Text( text = "@${profile.login}", maxLines = 1, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) profile.location?.let { location -> Spacer(Modifier.height(8.dp)) InfoChip( icon = Icons.Default.LocationOn, text = location, ) } profile.bio?.let { bio -> Spacer(modifier = Modifier.height(8.dp)) Text( text = bio, maxLines = 4, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } Spacer(modifier = Modifier.height(12.dp)) FlowRow( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), itemVerticalAlignment = Alignment.CenterVertically, ) { profile.company?.let { company -> InfoChip( icon = Icons.Default.Business, text = company, ) } profile.blog?.takeIf { it.isNotBlank() }?.let { blog -> val displayUrl = blog.removePrefix("https://").removePrefix("http://") AssistChip( onClick = { val url = if (!blog.startsWith("http")) "https://$blog" else blog onAction(DeveloperProfileAction.OnOpenLink(url)) }, label = { Text( text = displayUrl, style = MaterialTheme.typography.labelMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, ) }, leadingIcon = { Icon( imageVector = Icons.Default.Link, contentDescription = null, modifier = Modifier.size(16.dp), ) }, ) } profile.twitterUsername?.let { twitter -> AssistChip( onClick = { onAction(DeveloperProfileAction.OnOpenLink("https://twitter.com/$twitter")) }, label = { Text( text = "@$twitter", style = MaterialTheme.typography.labelMedium, ) }, leadingIcon = { Icon( imageVector = Icons.Default.Tag, contentDescription = null, modifier = Modifier.size(16.dp), ) }, ) } } } } } @Composable private fun InfoChip( icon: ImageVector, text: String, ) { Row( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( imageVector = icon, contentDescription = null, modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( text = text, maxLines = 1, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } ================================================ FILE: feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/components/StatsRow.kt ================================================ package zed.rainxch.devprofile.presentation.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import zed.rainxch.core.presentation.components.ExpressiveCard import zed.rainxch.core.presentation.utils.formatCount import zed.rainxch.devprofile.domain.model.DeveloperProfile import zed.rainxch.githubstore.core.presentation.res.* @Composable fun StatsRow(profile: DeveloperProfile) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { StatCard( label = stringResource(Res.string.repositories), value = profile.publicRepos.toString(), modifier = Modifier.weight(1f), ) StatCard( label = stringResource(Res.string.followers), value = formatCount(profile.followers), modifier = Modifier.weight(1f), ) StatCard( label = stringResource(Res.string.following), value = formatCount(profile.following), modifier = Modifier.weight(1f), ) } } @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun StatCard( label: String, value: String, modifier: Modifier = Modifier, ) { ExpressiveCard(modifier = modifier) { Column( modifier = Modifier .fillMaxWidth() .padding(12.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { Text( text = value, maxLines = 1, style = MaterialTheme.typography.titleLarge, overflow = TextOverflow.Ellipsis, color = MaterialTheme.colorScheme.onSurface, ) Text( text = label, maxLines = 1, style = MaterialTheme.typography.bodyMedium, overflow = TextOverflow.Ellipsis, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } ================================================ FILE: feature/favourites/CLAUDE.md ================================================ # CLAUDE.md - Favourites Feature ## Purpose Displays the user's locally saved favorite repositories. This is a **presentation-only** feature with no domain or data layer -- it uses `FavouritesRepository` from `core/domain` directly. ## Module Structure ``` feature/favourites/ └── presentation/ ├── FavouritesViewModel.kt # Observes favourites, handles remove ├── FavouritesState.kt # favourites list, loading ├── FavouritesAction.kt # RemoveFavourite, click actions ├── FavouritesRoot.kt # Main composable (list of favourites) ├── model/FavouriteRepository.kt # UI model for display ├── mappers/FavouriteRepositoryMapper.kt # Domain → UI model mapper └── components/FavouriteRepositoryItem.kt # Individual favourite card ``` ## Key Dependencies - `FavouritesRepository` (from `core/domain`) - CRUD operations for favourites - Favourites are stored locally in Room database (`FavoriteRepoDao` in `core/data`) ## Navigation Route: `GithubStoreGraph.FavouritesScreen` (data object, no params) ## Implementation Notes - No network calls -- all data is local (Room database) - Uses a presentation-layer `FavouriteRepository` UI model mapped from the domain `FavoriteRepo` - Adding to favourites happens in other features (home, details, search); this feature only displays and removes - The Koin module for this feature is registered in `composeApp/.../app/di/ViewModelsModule.kt` since there's no `data/di/` layer ================================================ FILE: feature/favourites/data/.gitignore ================================================ /build ================================================ FILE: feature/favourites/data/build.gradle.kts ================================================ plugins { alias(libs.plugins.convention.kmp.library) alias(libs.plugins.convention.buildkonfig) } kotlin { sourceSets { commonMain { dependencies { implementation(libs.kotlin.stdlib) implementation(projects.core.domain) implementation(projects.feature.favourites.domain) } } androidMain { dependencies { } } jvmMain { dependencies { } } } } ================================================ FILE: feature/favourites/data/src/androidMain/AndroidManifest.xml ================================================ ================================================ FILE: feature/favourites/domain/.gitignore ================================================ /build ================================================ FILE: feature/favourites/domain/build.gradle.kts ================================================ plugins { alias(libs.plugins.convention.kmp.library) } kotlin { sourceSets { commonMain { dependencies { implementation(libs.kotlin.stdlib) implementation(projects.core.domain) } } androidMain { dependencies { } } jvmMain { dependencies { } } } } ================================================ FILE: feature/favourites/domain/src/androidMain/AndroidManifest.xml ================================================ ================================================ FILE: feature/favourites/presentation/.gitignore ================================================ /build ================================================ FILE: feature/favourites/presentation/build.gradle.kts ================================================ plugins { alias(libs.plugins.convention.cmp.feature) } kotlin { sourceSets { commonMain { dependencies { implementation(libs.kotlin.stdlib) implementation(projects.core.domain) implementation(projects.core.presentation) implementation(projects.feature.favourites.domain) implementation(libs.bundles.landscapist) implementation(libs.kotlinx.collections.immutable) implementation(libs.androidx.compose.ui.tooling.preview) implementation(compose.components.resources) } } androidMain { dependencies { } } jvmMain { dependencies { } } } } ================================================ FILE: feature/favourites/presentation/src/androidMain/AndroidManifest.xml ================================================ ================================================ FILE: feature/favourites/presentation/src/commonMain/kotlin/zed/rainxch/favourites/presentation/FavouritesAction.kt ================================================ package zed.rainxch.favourites.presentation import zed.rainxch.favourites.presentation.model.FavouriteRepository sealed interface FavouritesAction { data object OnNavigateBackClick : FavouritesAction data class OnToggleFavorite( val favouriteRepository: FavouriteRepository, ) : FavouritesAction data class OnRepositoryClick( val favouriteRepository: FavouriteRepository, ) : FavouritesAction data class OnDeveloperProfileClick( val username: String, ) : FavouritesAction } ================================================ FILE: feature/favourites/presentation/src/commonMain/kotlin/zed/rainxch/favourites/presentation/FavouritesRoot.kt ================================================ package zed.rainxch.favourites.presentation import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells import androidx.compose.foundation.lazy.staggeredgrid.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable 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.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import zed.rainxch.core.presentation.theme.GithubStoreTheme import zed.rainxch.favourites.presentation.components.FavouriteRepositoryItem import zed.rainxch.githubstore.core.presentation.res.* @Composable fun FavouritesRoot( onNavigateBack: () -> Unit, onNavigateToDetails: (repoId: Long) -> Unit, onNavigateToDeveloperProfile: (username: String) -> Unit, viewModel: FavouritesViewModel = koinViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() FavouritesScreen( state = state, onAction = { action -> when (action) { FavouritesAction.OnNavigateBackClick -> { onNavigateBack() } is FavouritesAction.OnRepositoryClick -> { onNavigateToDetails(action.favouriteRepository.repoId) } is FavouritesAction.OnDeveloperProfileClick -> { onNavigateToDeveloperProfile(action.username) } else -> { viewModel.onAction(action) } } }, ) } @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun FavouritesScreen( state: FavouritesState, onAction: (FavouritesAction) -> Unit, ) { Scaffold( topBar = { FavouritesTopbar(onAction) }, containerColor = MaterialTheme.colorScheme.background, ) { innerPadding -> Box( modifier = Modifier .fillMaxSize() .padding(innerPadding), ) { LazyVerticalStaggeredGrid( columns = StaggeredGridCells.Adaptive( 350.dp, ), verticalItemSpacing = 12.dp, horizontalArrangement = Arrangement.spacedBy(12.dp), contentPadding = PaddingValues(horizontal = 8.dp, vertical = 12.dp), modifier = Modifier.fillMaxSize(), ) { items( items = state.favouriteRepositories, key = { it.repoId }, ) { repo -> FavouriteRepositoryItem( favouriteRepository = repo, onToggleFavouriteClick = { onAction(FavouritesAction.OnToggleFavorite(repo)) }, onItemClick = { onAction(FavouritesAction.OnRepositoryClick(repo)) }, onDevProfileClick = { onAction(FavouritesAction.OnDeveloperProfileClick(repo.repoOwner)) }, modifier = Modifier.animateItem(), ) } } if (state.isLoading) { CircularWavyProgressIndicator( modifier = Modifier.align(Alignment.Center), ) } } } } @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable private fun FavouritesTopbar(onAction: (FavouritesAction) -> Unit) { TopAppBar( title = { Text( text = stringResource(Res.string.favourites), style = MaterialTheme.typography.titleMediumEmphasized, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface, ) }, navigationIcon = { IconButton( shapes = IconButtonDefaults.shapes(), onClick = { onAction(FavouritesAction.OnNavigateBackClick) }, ) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.navigate_back), modifier = Modifier.size(24.dp), ) } }, ) } @Preview @Composable private fun Preview() { GithubStoreTheme { FavouritesScreen( state = FavouritesState(), onAction = {}, ) } } ================================================ FILE: feature/favourites/presentation/src/commonMain/kotlin/zed/rainxch/favourites/presentation/FavouritesState.kt ================================================ package zed.rainxch.favourites.presentation import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import zed.rainxch.favourites.presentation.model.FavouriteRepository data class FavouritesState( val favouriteRepositories: ImmutableList = persistentListOf(), val isLoading: Boolean = false, ) ================================================ FILE: feature/favourites/presentation/src/commonMain/kotlin/zed/rainxch/favourites/presentation/FavouritesViewModel.kt ================================================ package zed.rainxch.favourites.presentation import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import zed.rainxch.core.domain.model.FavoriteRepo import zed.rainxch.core.domain.repository.FavouritesRepository import zed.rainxch.favourites.presentation.mappers.toFavouriteRepositoryUi import kotlin.time.Clock import kotlin.time.ExperimentalTime class FavouritesViewModel( private val favouritesRepository: FavouritesRepository, ) : ViewModel() { private var hasLoadedInitialData = false private val _state = MutableStateFlow(FavouritesState()) val state = _state .onStart { if (!hasLoadedInitialData) { loadFavouriteRepos() hasLoadedInitialData = true } }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000L), initialValue = FavouritesState(), ) private fun loadFavouriteRepos() { viewModelScope.launch { favouritesRepository .getAllFavorites() .map { it.map { it.toFavouriteRepositoryUi() } } .flowOn(Dispatchers.Default) .collect { favoriteRepos -> _state.update { it.copy( favouriteRepositories = favoriteRepos.toImmutableList(), ) } } } } @OptIn(ExperimentalTime::class) fun onAction(action: FavouritesAction) { when (action) { FavouritesAction.OnNavigateBackClick -> { // Handled in composable } is FavouritesAction.OnRepositoryClick -> { // Handled in composable } is FavouritesAction.OnDeveloperProfileClick -> { // Handled in composable } is FavouritesAction.OnToggleFavorite -> { viewModelScope.launch { val repo = action.favouriteRepository val favoriteRepo = FavoriteRepo( repoId = repo.repoId, repoName = repo.repoName, repoOwner = repo.repoOwner, repoOwnerAvatarUrl = repo.repoOwnerAvatarUrl, repoDescription = repo.repoDescription, primaryLanguage = repo.primaryLanguage, repoUrl = repo.repoUrl, latestVersion = repo.latestRelease, latestReleaseUrl = repo.latestReleaseUrl, addedAt = Clock.System.now().toEpochMilliseconds(), lastSyncedAt = Clock.System.now().toEpochMilliseconds(), ) favouritesRepository.toggleFavorite(favoriteRepo) } } } } } ================================================ FILE: feature/favourites/presentation/src/commonMain/kotlin/zed/rainxch/favourites/presentation/components/FavouriteRepositoryItem.kt ================================================ package zed.rainxch.favourites.presentation.components import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll 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.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.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CalendarToday import androidx.compose.material.icons.filled.Code import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.NewReleases import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialShapes import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.toShape 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 androidx.compose.ui.unit.dp import com.skydoves.landscapist.coil3.CoilImage import com.skydoves.landscapist.components.rememberImageComponent import com.skydoves.landscapist.crossfade.CrossfadePlugin import org.jetbrains.compose.resources.stringResource import zed.rainxch.core.presentation.components.ExpressiveCard import zed.rainxch.favourites.presentation.model.FavouriteRepository import zed.rainxch.githubstore.core.presentation.res.Res import zed.rainxch.githubstore.core.presentation.res.remove_from_favourites @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun FavouriteRepositoryItem( favouriteRepository: FavouriteRepository, onToggleFavouriteClick: () -> Unit, onItemClick: () -> Unit, onDevProfileClick: () -> Unit, modifier: Modifier = Modifier, ) { ExpressiveCard( modifier = modifier, onClick = onItemClick, ) { Column( modifier = Modifier .fillMaxWidth() .padding(16.dp), ) { Row( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(24.dp)) .clickable(onClick = onDevProfileClick), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { CoilImage( imageModel = { favouriteRepository.repoOwnerAvatarUrl }, modifier = Modifier .size(32.dp) .clip(CircleShape), loading = { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { CircularWavyProgressIndicator() } }, component = rememberImageComponent { CrossfadePlugin() }, ) Text( text = favouriteRepository.repoOwner, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.outline, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), ) } Spacer(modifier = Modifier.height(8.dp)) Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { Column( modifier = Modifier.weight(1f), ) { Text( text = favouriteRepository.repoName, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis, ) favouriteRepository.repoDescription?.let { Spacer(modifier = Modifier.height(4.dp)) Text( text = it, fontWeight = FontWeight.Medium, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis, ) } } IconButton( onClick = onToggleFavouriteClick, colors = IconButtonDefaults.filledTonalIconButtonColors(), modifier = Modifier.align(Alignment.CenterVertically), shape = MaterialShapes.Cookie6Sided.toShape(), ) { Icon( imageVector = Icons.Default.Favorite, contentDescription = stringResource(Res.string.remove_from_favourites), ) } } Spacer(modifier = Modifier.height(12.dp)) Row( modifier = Modifier .fillMaxWidth() .horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { favouriteRepository.primaryLanguage?.let { language -> AssistChip( onClick = { /* No action */ }, label = { Text( text = language, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis, ) }, leadingIcon = { Icon( imageVector = Icons.Default.Code, contentDescription = null, modifier = Modifier.size(AssistChipDefaults.IconSize), ) }, colors = AssistChipDefaults.assistChipColors( containerColor = MaterialTheme.colorScheme.primaryContainer, labelColor = MaterialTheme.colorScheme.onPrimaryContainer, leadingIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer, ), ) } favouriteRepository.latestRelease?.let { release -> AssistChip( onClick = { /* No action */ }, label = { Text( text = release, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis, ) }, leadingIcon = { Icon( imageVector = Icons.Default.NewReleases, contentDescription = null, modifier = Modifier.size(AssistChipDefaults.IconSize), ) }, ) } AssistChip( onClick = { /* No action */ }, label = { Text( text = favouriteRepository.addedAtFormatter, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis, ) }, leadingIcon = { Icon( imageVector = Icons.Default.CalendarToday, contentDescription = null, modifier = Modifier.size(AssistChipDefaults.IconSize), ) }, ) } } } } ================================================ FILE: feature/favourites/presentation/src/commonMain/kotlin/zed/rainxch/favourites/presentation/mappers/FavouriteRepositoryMapper.kt ================================================ package zed.rainxch.favourites.presentation.mappers import zed.rainxch.core.domain.model.FavoriteRepo import zed.rainxch.core.presentation.utils.formatAddedAt import zed.rainxch.favourites.presentation.model.FavouriteRepository suspend fun FavoriteRepo.toFavouriteRepositoryUi(): FavouriteRepository = FavouriteRepository( repoId = repoId, repoName = repoName, repoOwner = repoOwner, repoOwnerAvatarUrl = repoOwnerAvatarUrl, repoDescription = repoDescription, primaryLanguage = primaryLanguage, repoUrl = repoUrl, latestRelease = latestVersion, latestReleaseUrl = latestReleaseUrl, addedAtFormatter = formatAddedAt(addedAt), ) ================================================ FILE: feature/favourites/presentation/src/commonMain/kotlin/zed/rainxch/favourites/presentation/model/FavouriteRepository.kt ================================================ package zed.rainxch.favourites.presentation.model data class FavouriteRepository( val repoId: Long, val repoName: String, val repoOwner: String, val repoOwnerAvatarUrl: String, val repoDescription: String?, val primaryLanguage: String?, val repoUrl: String, val addedAtFormatter: String, val latestRelease: String?, val latestReleaseUrl: String?, ) ================================================ FILE: feature/home/CLAUDE.md ================================================ # CLAUDE.md - Home Feature ## Purpose Main discovery screen of the app. Displays repositories in three categories: **Trending**, **Hot Releases**, and **Most Popular**. Supports infinite-scroll pagination and integrates with installed apps, favourites, and starred status. ## Module Structure ``` feature/home/ ├── domain/ │ ├── model/HomeCategory.kt # Enum: TRENDING, HOT_RELEASE, MOST_POPULAR │ └── repository/HomeRepository.kt # Paginated flows per category ├── data/ │ ├── di/SharedModule.kt # Koin: homeModule │ ├── repository/HomeRepositoryImpl.kt # GitHub API calls with caching & pagination │ ├── data_source/CachedRepositoriesDataSource.kt # Per-category cache (7-day expiry) │ ├── dto/ # Network DTOs │ └── mappers/ # DTO → domain model mappers └── presentation/ ├── HomeViewModel.kt # State management, pagination logic ├── HomeState.kt # repos, isLoading, category, hasMorePages, etc. ├── HomeAction.kt # Refresh, Retry, LoadMore, SwitchCategory, clicks ├── HomeEvent.kt # OnScrollToListTop ├── HomeRoot.kt # Main composable (staggered grid + filter chips) ├── components/HomeFilterChips.kt # Category filter chip row ├── locals/LocalHomeTopBarLiquid.kt └── utils/HomeCategoryMapper.kt # Map HomeCategory to display strings ``` ## Key Interfaces ```kotlin interface HomeRepository { fun getTrendingRepositories(page: Int): Flow fun getHotReleaseRepositories(page: Int): Flow fun getMostPopular(page: Int): Flow } ``` ## ViewModel Dependencies `HomeViewModel` depends on: `HomeRepository`, `InstalledAppsRepository`, `Platform`, `SyncInstalledAppsUseCase`, `FavouritesRepository`, `StarredRepository`, `GitHubStoreLogger` ## Navigation Route: `GithubStoreGraph.HomeScreen` (data object, no params) ## Implementation Notes - Uses `Semaphore` in `HomeRepositoryImpl` for concurrent request control - Cache is per-category with 7-day TTL in `CachedRepositoriesDataSource` - Pagination uses `nextPageIndex` tracking; deduplicates by `fullName` - Apps section visibility is platform-dependent (`Platform.ANDROID` only) - Observes installed apps, favourites, and starred repos reactively to update status badges - State uses `onStart` + `stateIn(WhileSubscribed)` for lazy initialization ================================================ FILE: feature/home/data/.gitignore ================================================ /build ================================================ FILE: feature/home/data/build.gradle.kts ================================================ plugins { alias(libs.plugins.convention.kmp.library) alias(libs.plugins.convention.buildkonfig) } kotlin { sourceSets { commonMain { dependencies { implementation(libs.kotlin.stdlib) implementation(projects.core.domain) implementation(projects.core.data) implementation(projects.feature.home.domain) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.datetime) implementation(libs.bundles.ktor.common) implementation(libs.bundles.koin.common) } } androidMain { dependencies { } } jvmMain { dependencies { } } } } ================================================ FILE: feature/home/data/src/androidMain/AndroidManifest.xml ================================================ ================================================ FILE: feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/data_source/CachedRepositoriesDataSource.kt ================================================ package zed.rainxch.home.data.data_source import zed.rainxch.core.domain.model.DiscoveryPlatform import zed.rainxch.home.data.dto.CachedRepoResponse interface CachedRepositoriesDataSource { suspend fun getCachedTrendingRepos(platform: DiscoveryPlatform): CachedRepoResponse? suspend fun getCachedHotReleaseRepos(platform: DiscoveryPlatform): CachedRepoResponse? suspend fun getCachedMostPopularRepos(platform: DiscoveryPlatform): CachedRepoResponse? } ================================================ FILE: feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/data_source/impl/CachedRepositoriesDataSourceImpl.kt ================================================ package zed.rainxch.home.data.data_source.impl import io.ktor.client.HttpClient import io.ktor.client.plugins.HttpTimeout import io.ktor.client.request.get import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.bodyAsText import io.ktor.http.isSuccess import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json import zed.rainxch.core.domain.logging.GitHubStoreLogger import zed.rainxch.core.domain.model.DiscoveryPlatform import zed.rainxch.home.data.data_source.CachedRepositoriesDataSource import zed.rainxch.home.data.dto.CachedGithubRepoSummary import zed.rainxch.home.data.dto.CachedRepoResponse import zed.rainxch.home.domain.model.HomeCategory import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Clock import kotlin.time.Duration.Companion.hours import kotlin.time.ExperimentalTime import kotlin.time.Instant @OptIn(ExperimentalTime::class) class CachedRepositoriesDataSourceImpl( private val logger: GitHubStoreLogger, ) : CachedRepositoriesDataSource { private val json = Json { ignoreUnknownKeys = true isLenient = true } private val httpClient = HttpClient { install(HttpTimeout) { requestTimeoutMillis = 10_000 connectTimeoutMillis = 5_000 socketTimeoutMillis = 10_000 } expectSuccess = false } private val cacheMutex = Mutex() private val memoryCache = mutableMapOf() private data class CacheEntry( val data: CachedRepoResponse, val fetchedAt: Instant, ) override suspend fun getCachedTrendingRepos(platform: DiscoveryPlatform): CachedRepoResponse? = fetchCachedReposForCategory(platform, HomeCategory.TRENDING) override suspend fun getCachedHotReleaseRepos(platform: DiscoveryPlatform): CachedRepoResponse? = fetchCachedReposForCategory(platform, HomeCategory.HOT_RELEASE) override suspend fun getCachedMostPopularRepos(platform: DiscoveryPlatform): CachedRepoResponse? = fetchCachedReposForCategory(platform, HomeCategory.MOST_POPULAR) private suspend fun fetchCachedReposForCategory( platform: DiscoveryPlatform, category: HomeCategory, ): CachedRepoResponse? { val cacheKey = CacheKey(platform, category) val cached = cacheMutex.withLock { memoryCache[cacheKey] } if (cached != null) { val age = Clock.System.now() - cached.fetchedAt if (age < CACHE_TTL) { logger.debug("Memory cache hit for $cacheKey (age: ${age.inWholeSeconds}s)") return cached.data } else { logger.debug("Memory cache expired for $cacheKey (age: ${age.inWholeSeconds}s)") } } return withContext(Dispatchers.IO) { val paths = when (category) { HomeCategory.TRENDING -> { listOf( "cached-data/trending/android.json", "cached-data/trending/windows.json", "cached-data/trending/macos.json", "cached-data/trending/linux.json", ) } HomeCategory.HOT_RELEASE -> { listOf( "cached-data/new-releases/android.json", "cached-data/new-releases/windows.json", "cached-data/new-releases/macos.json", "cached-data/new-releases/linux.json", ) } HomeCategory.MOST_POPULAR -> { listOf( "cached-data/most-popular/android.json", "cached-data/most-popular/windows.json", "cached-data/most-popular/macos.json", "cached-data/most-popular/linux.json", ) } } val responses = coroutineScope { paths .map { path -> async { val url = "https://raw.githubusercontent.com/OpenHub-Store/api/main/$path" val filePlatform = when { path.contains("/android") -> DiscoveryPlatform.Android path.contains("/windows") -> DiscoveryPlatform.Windows path.contains("/macos") -> DiscoveryPlatform.Macos path.contains("/linux") -> DiscoveryPlatform.Linux else -> error("Unknown platform in path: $path") } try { logger.debug("Fetching from: $url") val response: HttpResponse = httpClient.get(url) if (response.status.isSuccess()) { json .decodeFromString(response.bodyAsText()) .let { repoResponse -> repoResponse.copy( repositories = repoResponse.repositories.map { it.copy(availablePlatforms = listOf(filePlatform)) }, ) } } else { logger.error("HTTP ${response.status.value} from $url") null } } catch (e: SerializationException) { logger.error("Parse error from $url: ${e.message}") null } catch (e: CancellationException) { throw e } catch (e: Exception) { logger.error("Error with $url: ${e.message}") null } } }.awaitAll() .filterNotNull() } if (responses.isEmpty()) { logger.error("All mirrors failed for $cacheKey") return@withContext null } val allMergedRepos = responses .asSequence() .flatMap { it.repositories.asSequence() } .groupBy { it.id } .values .map { duplicates -> duplicates.reduce { acc, repo -> acc.copy( availablePlatforms = (acc.availablePlatforms + repo.availablePlatforms).distinct(), trendingScore = listOfNotNull( acc.trendingScore, repo.trendingScore, ).maxOrNull(), popularityScore = listOfNotNull( acc.popularityScore, repo.popularityScore, ).maxOrNull(), latestReleaseDate = listOfNotNull( acc.latestReleaseDate, repo.latestReleaseDate, ).maxOrNull(), ) } }.sortedWith( compareByDescending { it.trendingScore } .thenByDescending { it.popularityScore } .thenByDescending { it.latestReleaseDate }, ) val filteredRepos = when (platform) { DiscoveryPlatform.All -> allMergedRepos else -> allMergedRepos.filter { platform in it.availablePlatforms } }.toList() val merged = CachedRepoResponse( category = responses.first().category, platform = platform.name.lowercase(), lastUpdated = responses.maxOf { it.lastUpdated }, totalCount = filteredRepos.size, repositories = filteredRepos, ) if (responses.size == paths.size) { cacheMutex.withLock { memoryCache[cacheKey] = CacheEntry(data = merged, fetchedAt = Clock.System.now()) } } merged } } private companion object { private val CACHE_TTL = 1.hours } private data class CacheKey( val platform: DiscoveryPlatform, val category: HomeCategory, ) } ================================================ FILE: feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/di/SharedModule.kt ================================================ package zed.rainxch.home.data.di import org.koin.dsl.module import zed.rainxch.home.data.data_source.CachedRepositoriesDataSource import zed.rainxch.home.data.data_source.impl.CachedRepositoriesDataSourceImpl import zed.rainxch.home.data.repository.HomeRepositoryImpl import zed.rainxch.home.domain.repository.HomeRepository val homeModule = module { single { HomeRepositoryImpl( cachedDataSource = get(), httpClient = get(), devicePlatform = get(), logger = get(), cacheManager = get(), ) } single { CachedRepositoriesDataSourceImpl( logger = get(), ) } } ================================================ FILE: feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/dto/CachedGithubOwner.kt ================================================ package zed.rainxch.home.data.dto import kotlinx.serialization.Serializable @Serializable data class CachedGithubOwner( val login: String, val avatarUrl: String, ) ================================================ FILE: feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/dto/CachedGithubRepoSummary.kt ================================================ package zed.rainxch.home.data.dto import kotlinx.serialization.Serializable import zed.rainxch.core.domain.model.DiscoveryPlatform @Serializable data class CachedGithubRepoSummary( val id: Long, val name: String, val fullName: String, val owner: CachedGithubOwner, val description: String?, val defaultBranch: String, val htmlUrl: String, val stargazersCount: Int, val forksCount: Int, val language: String?, val topics: List?, val releasesUrl: String, val updatedAt: String, val latestReleaseDate: String? = null, val trendingScore: Double? = null, val popularityScore: Int? = null, val availablePlatforms: List = emptyList(), ) ================================================ FILE: feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/dto/CachedRepoResponse.kt ================================================ package zed.rainxch.home.data.dto import kotlinx.serialization.Serializable @Serializable data class CachedRepoResponse( val category: String? = null, val platform: String, val lastUpdated: String, val totalCount: Int, val repositories: List, ) ================================================ FILE: feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/mappers/CachedGithubRepoSummaryMappers.kt ================================================ package zed.rainxch.home.data.mappers import zed.rainxch.core.domain.model.GithubRepoSummary import zed.rainxch.core.domain.model.GithubUser import zed.rainxch.home.data.dto.CachedGithubRepoSummary fun CachedGithubRepoSummary.toGithubRepoSummary(): GithubRepoSummary = GithubRepoSummary( id = id, name = name, fullName = fullName, owner = GithubUser( id = 0, login = owner.login, avatarUrl = owner.avatarUrl, htmlUrl = "https://github.com/${owner.login}", ), description = description, defaultBranch = defaultBranch, htmlUrl = htmlUrl, stargazersCount = stargazersCount, forksCount = forksCount, language = language, topics = topics, releasesUrl = releasesUrl, updatedAt = latestReleaseDate ?: updatedAt, availablePlatforms = availablePlatforms, ) ================================================ FILE: feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/repository/HomeRepositoryImpl.kt ================================================ @file:OptIn(ExperimentalTime::class) package zed.rainxch.home.data.repository import io.ktor.client.HttpClient import io.ktor.client.request.get import io.ktor.client.request.header import io.ktor.client.request.parameter import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withTimeoutOrNull import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import zed.rainxch.core.data.cache.CacheManager import zed.rainxch.core.data.cache.CacheManager.CacheTtl.HOME_REPOS import zed.rainxch.core.data.dto.GithubRepoNetworkModel import zed.rainxch.core.data.dto.GithubRepoSearchResponse import zed.rainxch.core.data.mappers.toSummary import zed.rainxch.core.data.network.executeRequest import zed.rainxch.core.domain.logging.GitHubStoreLogger import zed.rainxch.core.domain.model.DiscoveryPlatform import zed.rainxch.core.domain.model.GithubRepoSummary import zed.rainxch.core.domain.model.PaginatedDiscoveryRepositories import zed.rainxch.core.domain.model.Platform import zed.rainxch.core.domain.model.RateLimitException import zed.rainxch.home.data.data_source.CachedRepositoriesDataSource import zed.rainxch.home.data.mappers.toGithubRepoSummary import zed.rainxch.home.domain.repository.HomeRepository import kotlin.time.Clock import kotlin.time.Duration.Companion.days import kotlin.time.ExperimentalTime class HomeRepositoryImpl( private val httpClient: HttpClient, private val devicePlatform: Platform, private val cachedDataSource: CachedRepositoriesDataSource, private val logger: GitHubStoreLogger, private val cacheManager: CacheManager, ) : HomeRepository { private fun cacheKey( category: String, requestedPlatform: DiscoveryPlatform, page: Int, ): String = "home:$category:${requestedPlatform.name}:page$page" @OptIn(ExperimentalTime::class) override fun getTrendingRepositories( platform: DiscoveryPlatform, page: Int, ): Flow = flow { if (page == 1) { logger.debug("Attempting to load cached trending repositories...") val cachedData = cachedDataSource.getCachedTrendingRepos(platform) if (cachedData != null && cachedData.repositories.isNotEmpty()) { logger.debug("Using mirror cached data: ${cachedData.repositories.size} repos") val repos = cachedData.repositories.map { it.toGithubRepoSummary() } val result = PaginatedDiscoveryRepositories( repos = repos, hasMore = false, nextPageIndex = 2, ) cacheManager.put( key = cacheKey( category = "trending", requestedPlatform = platform, page = page, ), value = result, ttlMillis = HOME_REPOS, ) emit(result) return@flow } else { logger.debug("No mirror data, checking local cache...") } } val localCached = cacheManager.get( cacheKey( category = "trending", requestedPlatform = platform, page = page, ), ) if (localCached != null && localCached.repos.isNotEmpty()) { logger.debug("Using locally cached trending repos: ${localCached.repos.size}") emit(localCached) return@flow } val thirtyDaysAgo = Clock.System .now() .minus(30.days) .toLocalDateTime(TimeZone.UTC) .date emitAll( searchReposWithInstallersFlow( platform = platform, baseQuery = "stars:>50 archived:false pushed:>=$thirtyDaysAgo", sort = "stars", order = "desc", startPage = page, category = "trending", ), ) }.flowOn(Dispatchers.IO) @OptIn(ExperimentalTime::class) override fun getHotReleaseRepositories( platform: DiscoveryPlatform, page: Int, ): Flow = flow { if (page == 1) { logger.debug("Attempting to load cached hot release repositories...") val cachedData = cachedDataSource.getCachedHotReleaseRepos(platform) if (cachedData != null && cachedData.repositories.isNotEmpty()) { logger.debug("Using mirror cached data: ${cachedData.repositories.size} repos") val repos = cachedData.repositories.map { it.toGithubRepoSummary() } val result = PaginatedDiscoveryRepositories( repos = repos, hasMore = false, nextPageIndex = 2, ) cacheManager.put( key = cacheKey( category = "hot_release", requestedPlatform = platform, page = page, ), value = result, ttlMillis = HOME_REPOS, ) emit(result) return@flow } else { logger.debug("No mirror data, checking local cache...") } } val localCached = cacheManager.get( cacheKey( category = "hot_release", requestedPlatform = platform, page = page, ), ) if (localCached != null && localCached.repos.isNotEmpty()) { logger.debug("Using locally cached hot release repos: ${localCached.repos.size}") emit(localCached) return@flow } val fourteenDaysAgo = Clock.System .now() .minus(14.days) .toLocalDateTime(TimeZone.UTC) .date emitAll( searchReposWithInstallersFlow( platform = platform, baseQuery = "stars:>10 archived:false pushed:>=$fourteenDaysAgo", sort = "updated", order = "desc", startPage = page, category = "hot_release", ), ) }.flowOn(Dispatchers.IO) @OptIn(ExperimentalTime::class) override fun getMostPopular( platform: DiscoveryPlatform, page: Int, ): Flow = flow { if (page == 1) { logger.debug("Attempting to load cached most popular repositories...") val cachedData = cachedDataSource.getCachedMostPopularRepos(platform) if (cachedData != null && cachedData.repositories.isNotEmpty()) { logger.debug("Using mirror cached data: ${cachedData.repositories.size} repos") val repos = cachedData.repositories.map { it.toGithubRepoSummary() } val result = PaginatedDiscoveryRepositories( repos = repos, hasMore = false, nextPageIndex = 2, ) cacheManager.put(cacheKey("most_popular", platform, page), result, HOME_REPOS) emit(result) return@flow } else { logger.debug("No mirror data, checking local cache...") } } val localCached = cacheManager.get( cacheKey( category = "most_popular", requestedPlatform = platform, page = page, ), ) if (localCached != null && localCached.repos.isNotEmpty()) { logger.debug("Using locally cached most popular repos: ${localCached.repos.size}") emit(localCached) return@flow } val sixMonthsAgo = Clock.System .now() .minus(180.days) .toLocalDateTime(TimeZone.UTC) .date val oneYearAgo = Clock.System .now() .minus(365.days) .toLocalDateTime(TimeZone.UTC) .date emitAll( searchReposWithInstallersFlow( platform = platform, baseQuery = "stars:>1000 archived:false created:<$sixMonthsAgo pushed:>=$oneYearAgo", sort = "stars", order = "desc", startPage = page, category = "most_popular", ), ) }.flowOn(Dispatchers.IO) private fun searchReposWithInstallersFlow( platform: DiscoveryPlatform, baseQuery: String, sort: String, order: String, startPage: Int, category: String, desiredCount: Int = 10, ): Flow = flow { val results = mutableListOf() var currentApiPage = startPage val perPage = 100 val semaphore = Semaphore(25) val maxPagesToFetch = 5 var pagesFetchedCount = 0 var lastEmittedCount = 0 val query = buildSimplifiedQuery(baseQuery, platform) logger.debug("Query: $query | Sort: $sort | Page: $startPage") while (results.size < desiredCount && pagesFetchedCount < maxPagesToFetch) { currentCoroutineContext().ensureActive() try { val response = httpClient .executeRequest { get("/search/repositories") { parameter("q", query) parameter("sort", sort) parameter("order", order) parameter("per_page", perPage) parameter("page", currentApiPage) } }.getOrElse { error -> logger.error("Search request failed: ${error.message}") throw error } logger.debug("API Page $currentApiPage: Got ${response.items.size} repos") if (response.items.isEmpty()) { logger.debug("No more items from API, breaking") break } val candidates = response.items .map { repo -> repo to calculatePlatformScore(repo) } .filter { it.second > 0 } .take(50) .map { it.first } logger.debug("Checking ${candidates.size} candidates for installers") coroutineScope { val deferredResults = candidates.map { repo -> async { semaphore.withPermit { withTimeoutOrNull(5000) { checkRepoHasInstallers(repo) } } } } for (deferred in deferredResults) { currentCoroutineContext().ensureActive() val result = deferred.await() if (result != null) { results.add(result) logger.debug("Found installer repo: ${result.fullName} (${results.size}/$desiredCount)") if (results.size % 3 == 0 || results.size >= desiredCount) { val newItems = results.subList(lastEmittedCount, results.size) if (newItems.isNotEmpty()) { val paginatedResult = PaginatedDiscoveryRepositories( repos = newItems.toList(), hasMore = true, nextPageIndex = currentApiPage + 1, ) emit(paginatedResult) logger.debug("Emitted ${newItems.size} repos (total: ${results.size})") lastEmittedCount = results.size } } if (results.size >= desiredCount) { logger.debug("Reached desired count, breaking") break } } } } if (results.size >= desiredCount || response.items.size < perPage) { logger.debug("Breaking: results=${results.size}, response size=${response.items.size}") break } currentApiPage++ pagesFetchedCount++ } catch (_: RateLimitException) { logger.error("Rate limited during search") break } catch (e: CancellationException) { throw e } catch (e: Exception) { logger.error("Search failed: ${e.message}") e.printStackTrace() break } } if (results.size > lastEmittedCount) { val finalBatch = results.subList(lastEmittedCount, results.size) val finalHasMore = pagesFetchedCount < maxPagesToFetch && results.size >= desiredCount val finalResult = PaginatedDiscoveryRepositories( repos = finalBatch.toList(), hasMore = finalHasMore, nextPageIndex = if (finalHasMore) currentApiPage + 1 else currentApiPage, ) emit(finalResult) logger.debug("Final emit: ${finalBatch.size} repos (total: ${results.size})") } else if (results.isEmpty()) { emit( PaginatedDiscoveryRepositories( repos = emptyList(), hasMore = false, nextPageIndex = currentApiPage, ), ) logger.debug("No results found") } if (results.isNotEmpty()) { val allResults = PaginatedDiscoveryRepositories( repos = results.toList(), hasMore = pagesFetchedCount < maxPagesToFetch && results.size >= desiredCount, nextPageIndex = currentApiPage + 1, ) cacheManager.put( key = cacheKey( category = category, requestedPlatform = platform, page = startPage, ), value = allResults, ttlMillis = HOME_REPOS, ) logger.debug("Cached ${results.size} repos for $category page $startPage") } }.flowOn(Dispatchers.IO) private fun buildSimplifiedQuery( baseQuery: String, requestedPlatform: DiscoveryPlatform, ): String { val topic = when (requestedPlatform) { DiscoveryPlatform.All -> null DiscoveryPlatform.Android -> "android" DiscoveryPlatform.Windows -> "desktop" DiscoveryPlatform.Macos -> "macos" DiscoveryPlatform.Linux -> "linux" } return if (topic == null) baseQuery else "$baseQuery topic:$topic" } private fun calculatePlatformScore(repo: GithubRepoNetworkModel): Int { var score = 5 val topics = repo.topics.orEmpty().map { it.lowercase() } val language = repo.language?.lowercase() val desc = repo.description?.lowercase() ?: "" when (devicePlatform) { Platform.ANDROID -> { if (topics.contains("android")) score += 10 if (topics.contains("mobile")) score += 5 if (language == "kotlin" || language == "java") score += 5 if (desc.contains("android") || desc.contains("apk")) score += 3 } Platform.WINDOWS, Platform.MACOS, Platform.LINUX -> { if (topics.any { it in setOf( "desktop", "electron", "app", "gui", "compose-desktop", ) } ) { score += 10 } if (topics.contains("cross-platform") || topics.contains("multiplatform")) score += 8 if (language in setOf("kotlin", "c++", "rust", "c#", "swift", "dart")) score += 5 if (desc.contains("desktop") || desc.contains("application")) score += 3 } } return score } private suspend fun checkRepoHasInstallers(repo: GithubRepoNetworkModel): GithubRepoSummary? { return try { val allReleases = httpClient .executeRequest> { get("/repos/${repo.owner.login}/${repo.name}/releases") { header("Accept", "application/vnd.github.v3+json") parameter("per_page", 10) } }.getOrNull() ?: return null val stableRelease = allReleases.firstOrNull { it.draft != true && it.prerelease != true } if (stableRelease == null || stableRelease.assets.isEmpty()) { return null } val relevantAssets = stableRelease.assets.filter { asset -> val name = asset.name.lowercase() when (devicePlatform) { Platform.ANDROID -> { name.endsWith(".apk") } Platform.WINDOWS -> { name.endsWith(".msi") || name.endsWith(".exe") } Platform.MACOS -> { name.endsWith(".dmg") || name.endsWith(".pkg") } Platform.LINUX -> { name.endsWith(".appimage") || name.endsWith(".deb") || name.endsWith( ".rpm", ) } } } if (relevantAssets.isNotEmpty()) { repo.toSummary() } else { null } } catch (_: Exception) { null } } @Serializable private data class GithubReleaseNetworkModel( val assets: List, val draft: Boolean? = null, val prerelease: Boolean? = null, @SerialName("published_at") val publishedAt: String? = null, ) @Serializable private data class AssetNetworkModel( val name: String, ) } ================================================ FILE: feature/home/domain/.gitignore ================================================ /build ================================================ FILE: feature/home/domain/build.gradle.kts ================================================ plugins { alias(libs.plugins.convention.kmp.library) } kotlin { sourceSets { commonMain { dependencies { implementation(libs.kotlin.stdlib) implementation(projects.core.domain) implementation(libs.kotlinx.coroutines.core) } } androidMain { dependencies { } } jvmMain { dependencies { } } } } ================================================ FILE: feature/home/domain/src/androidMain/AndroidManifest.xml ================================================ ================================================ FILE: feature/home/domain/src/commonMain/kotlin/zed/rainxch/home/domain/model/HomeCategory.kt ================================================ package zed.rainxch.home.domain.model enum class HomeCategory { TRENDING, HOT_RELEASE, MOST_POPULAR, } ================================================ FILE: feature/home/domain/src/commonMain/kotlin/zed/rainxch/home/domain/repository/HomeRepository.kt ================================================ package zed.rainxch.home.domain.repository import kotlinx.coroutines.flow.Flow import zed.rainxch.core.domain.model.DiscoveryPlatform import zed.rainxch.core.domain.model.PaginatedDiscoveryRepositories interface HomeRepository { fun getTrendingRepositories( platform: DiscoveryPlatform, page: Int, ): Flow fun getHotReleaseRepositories( platform: DiscoveryPlatform, page: Int, ): Flow fun getMostPopular( platform: DiscoveryPlatform, page: Int, ): Flow } ================================================ FILE: feature/home/presentation/.gitignore ================================================ /build ================================================ FILE: feature/home/presentation/build.gradle.kts ================================================ plugins { alias(libs.plugins.convention.cmp.feature) } kotlin { sourceSets { commonMain { dependencies { implementation(libs.kotlin.stdlib) implementation(projects.core.domain) implementation(projects.core.presentation) implementation(projects.feature.home.domain) implementation(libs.liquid) implementation(libs.kotlinx.collections.immutable) implementation(compose.components.resources) implementation(libs.androidx.compose.ui.tooling.preview) } } androidMain { dependencies { } } jvmMain { dependencies { } } } } ================================================ FILE: feature/home/presentation/src/androidMain/AndroidManifest.xml ================================================ ================================================ FILE: feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeAction.kt ================================================ package zed.rainxch.home.presentation import zed.rainxch.core.domain.model.DiscoveryPlatform import zed.rainxch.core.presentation.model.GithubRepoSummaryUi import zed.rainxch.home.domain.model.HomeCategory sealed interface HomeAction { data object Refresh : HomeAction data object Retry : HomeAction data object LoadMore : HomeAction data object OnSearchClick : HomeAction data object OnSettingsClick : HomeAction data object OnAppsClick : HomeAction data object OnTogglePlatformPopup : HomeAction data class OnShareClick( val repo: GithubRepoSummaryUi, ) : HomeAction data class SwitchCategory( val category: HomeCategory, ) : HomeAction data class SwitchFilterPlatform( val platform: DiscoveryPlatform, ) : HomeAction data class OnRepositoryClick( val repo: GithubRepoSummaryUi, ) : HomeAction data class OnRepositoryDeveloperClick( val username: String, ) : HomeAction } ================================================ FILE: feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeEvent.kt ================================================ package zed.rainxch.home.presentation sealed interface HomeEvent { data object OnScrollToListTop : HomeEvent data class OnMessage( val message: String, ) : HomeEvent } ================================================ FILE: feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeRoot.kt ================================================ package zed.rainxch.home.presentation import androidx.compose.foundation.Image import androidx.compose.foundation.background 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.PaddingValues import androidx.compose.foundation.layout.Row 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.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells import androidx.compose.foundation.lazy.staggeredgrid.items import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Done import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState 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.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.github.fletchmckee.liquid.LiquidState import io.github.fletchmckee.liquid.liquefiable import io.github.fletchmckee.liquid.rememberLiquidState import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import zed.rainxch.core.domain.model.DiscoveryPlatform import zed.rainxch.core.presentation.components.GithubStoreButton import zed.rainxch.core.presentation.components.RepositoryCard import zed.rainxch.core.presentation.locals.LocalBottomNavigationHeight import zed.rainxch.core.presentation.locals.LocalBottomNavigationLiquid import zed.rainxch.core.presentation.theme.GithubStoreTheme import zed.rainxch.core.presentation.utils.ObserveAsEvents import zed.rainxch.core.presentation.utils.toIcons import zed.rainxch.core.presentation.utils.toLabel import zed.rainxch.githubstore.core.presentation.res.* import zed.rainxch.home.domain.model.HomeCategory import zed.rainxch.home.presentation.components.LiquidGlassCategoryChips import zed.rainxch.home.presentation.locals.LocalHomeTopBarLiquid @Composable fun HomeRoot( onNavigateToSettings: () -> Unit, onNavigateToSearch: () -> Unit, onNavigateToApps: () -> Unit, onNavigateToDetails: (repoId: Long) -> Unit, onNavigateToDeveloperProfile: (username: String) -> Unit, viewModel: HomeViewModel = koinViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() val listState = rememberLazyStaggeredGridState() val scope = rememberCoroutineScope() val snackbarHost = remember { SnackbarHostState() } ObserveAsEvents(viewModel.events) { event -> when (event) { HomeEvent.OnScrollToListTop -> { scope.launch { listState.animateScrollToItem(0) } } is HomeEvent.OnMessage -> { scope.launch { snackbarHost.showSnackbar(event.message) } } } } HomeScreen( state = state, snackbarHost = snackbarHost, onAction = { action -> when (action) { HomeAction.OnSearchClick -> { onNavigateToSearch() } HomeAction.OnSettingsClick -> { onNavigateToSettings() } HomeAction.OnAppsClick -> { onNavigateToApps() } is HomeAction.OnRepositoryClick -> { onNavigateToDetails(action.repo.id) } is HomeAction.OnRepositoryDeveloperClick -> { onNavigateToDeveloperProfile(action.username) } else -> { viewModel.onAction(action) } } }, listState = listState, ) } @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun HomeScreen( state: HomeState, snackbarHost: SnackbarHostState, onAction: (HomeAction) -> Unit, listState: LazyStaggeredGridState, ) { val liquidState = LocalBottomNavigationLiquid.current val bottomNavHeight = LocalBottomNavigationHeight.current val shouldLoadMore by remember { derivedStateOf { val layoutInfo = listState.layoutInfo val totalItems = layoutInfo.totalItemsCount val lastVisibleItem = layoutInfo.visibleItemsInfo.lastOrNull() totalItems > 0 && lastVisibleItem != null && lastVisibleItem.index >= (totalItems - 5) && !state.isLoadingMore && !state.isLoading && state.hasMorePages } } val currentOnAction by rememberUpdatedState(onAction) LaunchedEffect(shouldLoadMore) { if (shouldLoadMore) { currentOnAction(HomeAction.LoadMore) } } val homeTopbarLiquidState = rememberLiquidState() CompositionLocalProvider( LocalHomeTopBarLiquid provides homeTopbarLiquidState, ) { Scaffold( topBar = { TopAppBar( currentPlatform = state.currentPlatform, onChangePlatform = { onAction(HomeAction.SwitchFilterPlatform(it)) }, isPlatformPopupVisible = state.isPlatformPopupVisible, onTogglePlatformPopup = { onAction(HomeAction.OnTogglePlatformPopup) }, ) }, snackbarHost = { SnackbarHost( hostState = snackbarHost, modifier = Modifier.padding(bottom = bottomNavHeight + 16.dp), ) }, containerColor = MaterialTheme.colorScheme.background, ) { innerPadding -> Column( modifier = Modifier .fillMaxSize() .padding(innerPadding) .padding(horizontal = 8.dp) .then( if (state.isLiquidGlassEnabled) { Modifier .liquefiable(liquidState) .liquefiable(homeTopbarLiquidState) } else { Modifier }, ), ) { FilterChips(state, onAction) Box(Modifier.fillMaxSize()) { LoadingState(state) ErrorState(state, onAction) MainState( state = state, listState = listState, onAction = onAction, bottomNavLiquidState = liquidState, homeTopBarLiquidState = homeTopbarLiquidState, ) } } } } } @Composable private fun MainState( state: HomeState, listState: LazyStaggeredGridState, onAction: (HomeAction) -> Unit, bottomNavLiquidState: LiquidState, homeTopBarLiquidState: LiquidState, ) { val visibleRepos by remember(state.repos, state.isHideSeenEnabled, state.seenRepoIds) { derivedStateOf { if (state.isHideSeenEnabled && state.seenRepoIds.isNotEmpty()) { state.repos.filter { it.repository.id !in state.seenRepoIds } } else { state.repos } } } if (visibleRepos.isNotEmpty()) { LazyVerticalStaggeredGrid( state = listState, columns = StaggeredGridCells.Adaptive(350.dp), verticalItemSpacing = 12.dp, horizontalArrangement = Arrangement.spacedBy(12.dp), contentPadding = PaddingValues(horizontal = 8.dp, vertical = 12.dp), modifier = Modifier.fillMaxSize(), ) { items( items = visibleRepos, key = { it.repository.id }, contentType = { "repo" }, ) { discoveryRepository -> RepositoryCard( discoveryRepositoryUi = discoveryRepository, onClick = { onAction(HomeAction.OnRepositoryClick(discoveryRepository.repository)) }, onDeveloperClick = { username -> onAction(HomeAction.OnRepositoryDeveloperClick(username)) }, onShareClick = { onAction(HomeAction.OnShareClick(discoveryRepository.repository)) }, modifier = Modifier .animateItem() .then( if (state.isLiquidGlassEnabled) { Modifier .liquefiable(bottomNavLiquidState) .liquefiable(homeTopBarLiquidState) } else { Modifier }, ), ) } if (state.isLoadingMore) { item(key = "loading_indicator") { Box( modifier = Modifier .fillMaxWidth() .padding(16.dp), contentAlignment = Alignment.Center, ) { Row( horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { CircularProgressIndicator( modifier = Modifier.size(20.dp), ) Spacer(modifier = Modifier.width(8.dp)) Text( text = stringResource(Res.string.home_loading_more), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } } if (!state.hasMorePages && !state.isLoadingMore) { item(key = "end_message") { Text( text = stringResource(Res.string.home_no_more_repositories), modifier = Modifier .fillMaxWidth() .padding(16.dp), textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.titleMedium, ) } } } } } @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun LoadingState(state: HomeState) { if (state.isLoading && state.repos.isEmpty()) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { CircularWavyProgressIndicator() Spacer(modifier = Modifier.height(8.dp)) Text( text = stringResource(Res.string.home_finding_repositories), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } } @Composable private fun ErrorState( state: HomeState, onAction: (HomeAction) -> Unit, ) { if (state.errorMessage != null && state.repos.isEmpty()) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(16.dp), ) { Text( text = state.errorMessage, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(modifier = Modifier.height(8.dp)) GithubStoreButton( text = stringResource(Res.string.home_retry), onClick = { onAction(HomeAction.Retry) }, ) } } } } @Composable private fun FilterChips( state: HomeState, onAction: (HomeAction) -> Unit, ) { LiquidGlassCategoryChips( categories = HomeCategory.entries.toList(), selectedCategory = state.currentCategory, onCategorySelected = { category -> onAction(HomeAction.SwitchCategory(category)) }, isLiquidGlassEnabled = state.isLiquidGlassEnabled, ) } @Composable @OptIn(ExperimentalMaterial3Api::class) private fun TopAppBar( currentPlatform: DiscoveryPlatform, onChangePlatform: (DiscoveryPlatform) -> Unit, isPlatformPopupVisible: Boolean, onTogglePlatformPopup: () -> Unit, ) { TopAppBar( navigationIcon = { Image( painter = painterResource(Res.drawable.app_icon), contentDescription = null, modifier = Modifier .size(48.dp) .clip(CircleShape) .background(Color(0xff121212)) .padding(4.dp), contentScale = ContentScale.Crop, ) }, title = { Text( text = stringResource(Res.string.app_name), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onBackground, fontWeight = FontWeight.Black, modifier = Modifier.padding(start = 4.dp), maxLines = 2, softWrap = false, overflow = TextOverflow.Ellipsis, ) }, actions = { val icons = currentPlatform.toIcons() Row( modifier = Modifier .clip(RoundedCornerShape(16.dp)) .background(MaterialTheme.colorScheme.surfaceContainerHigh) .clickable(onClick = onTogglePlatformPopup) .padding(vertical = 4.dp, horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp), ) { icons.forEach { icon -> Icon( imageVector = icon, contentDescription = null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurface, ) } } if (isPlatformPopupVisible) { Box { PlatformsPopup( onTogglePlatformPopup = onTogglePlatformPopup, onChangePlatform = onChangePlatform, currentPlatform = currentPlatform, ) } } }, modifier = Modifier.padding(12.dp), ) } @Composable private fun PlatformsPopup( onTogglePlatformPopup: () -> Unit, onChangePlatform: (DiscoveryPlatform) -> Unit, currentPlatform: DiscoveryPlatform, ) { Popup( onDismissRequest = onTogglePlatformPopup, ) { Column( modifier = Modifier .clip(RoundedCornerShape(8.dp)) .background(MaterialTheme.colorScheme.surfaceContainerHighest) .padding(6.dp), ) { DiscoveryPlatform.entries.forEach { platform -> Box( modifier = Modifier .clickable(onClick = { onChangePlatform(platform) onTogglePlatformPopup() }) .padding(horizontal = 32.dp, vertical = 8.dp), ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy( 6.dp, Alignment.Start, ), ) { if (currentPlatform == platform) { Icon( imageVector = Icons.Default.Done, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(24.dp), ) } Text( text = platform.toLabel(), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onBackground, ) } } } } } } @Preview @Composable private fun Preview() { GithubStoreTheme { val liquidState = rememberLiquidState() CompositionLocalProvider( value = LocalBottomNavigationLiquid provides liquidState, ) { HomeScreen( state = HomeState(), onAction = {}, snackbarHost = SnackbarHostState(), listState = rememberLazyStaggeredGridState(), ) } } } ================================================ FILE: feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeState.kt ================================================ package zed.rainxch.home.presentation import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import zed.rainxch.core.domain.model.DiscoveryPlatform import zed.rainxch.core.domain.model.InstalledApp import zed.rainxch.core.presentation.model.DiscoveryRepositoryUi import zed.rainxch.home.domain.model.HomeCategory data class HomeState( val repos: ImmutableList = persistentListOf(), val installedApps: ImmutableList = persistentListOf(), val isLoading: Boolean = false, val isLoadingMore: Boolean = false, val errorMessage: String? = null, val hasMorePages: Boolean = true, val currentCategory: HomeCategory = HomeCategory.TRENDING, val isAppsSectionVisible: Boolean = false, val isUpdateAvailable: Boolean = false, val currentPlatform: DiscoveryPlatform = DiscoveryPlatform.All, val isPlatformPopupVisible: Boolean = false, val isLiquidGlassEnabled: Boolean = true, val isHideSeenEnabled: Boolean = false, val seenRepoIds: Set = emptySet(), ) ================================================ FILE: feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeViewModel.kt ================================================ package zed.rainxch.home.presentation import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.jetbrains.compose.resources.getString import zed.rainxch.core.domain.logging.GitHubStoreLogger import zed.rainxch.core.domain.model.DiscoveryPlatform import zed.rainxch.core.domain.model.Platform import zed.rainxch.core.domain.repository.FavouritesRepository import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.repository.SeenReposRepository import zed.rainxch.core.domain.repository.StarredRepository import zed.rainxch.core.domain.use_cases.SyncInstalledAppsUseCase import zed.rainxch.core.domain.repository.TweaksRepository import zed.rainxch.core.domain.utils.ShareManager import zed.rainxch.core.presentation.model.DiscoveryRepositoryUi import zed.rainxch.core.presentation.utils.toUi import zed.rainxch.githubstore.core.presentation.res.* import zed.rainxch.home.domain.model.HomeCategory import zed.rainxch.home.domain.repository.HomeRepository import zed.rainxch.home.presentation.HomeEvent.* class HomeViewModel( private val homeRepository: HomeRepository, private val installedAppsRepository: InstalledAppsRepository, private val platform: Platform, private val syncInstalledAppsUseCase: SyncInstalledAppsUseCase, private val favouritesRepository: FavouritesRepository, private val starredRepository: StarredRepository, private val logger: GitHubStoreLogger, private val shareManager: ShareManager, private val tweaksRepository: TweaksRepository, private val seenReposRepository: SeenReposRepository, ) : ViewModel() { private var hasLoadedInitialData = false private var currentJob: Job? = null private var switchCategoryJob: Job? = null private var nextPageIndex = 1 private val _state = MutableStateFlow(HomeState()) val state = _state .onStart { if (!hasLoadedInitialData) { syncSystemState() loadPlatform() loadRepos(isInitial = true) observeInstalledApps() observeFavourites() observeStarredRepos() observeLiquidGlassEnabled() observeSeenRepos() observeHideSeenEnabled() hasLoadedInitialData = true } }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000L), initialValue = HomeState(), ) private val _events = Channel() val events = _events.receiveAsFlow() private fun syncSystemState() { viewModelScope.launch { try { val result = syncInstalledAppsUseCase() if (result.isFailure) { logger.warn("Initial sync had issues: ${result.exceptionOrNull()?.message}") } } catch (e: Exception) { logger.error("Initial sync failed: ${e.message}") } } } private fun loadPlatform() { _state.update { it.copy(isAppsSectionVisible = platform == Platform.ANDROID) } } private fun observeInstalledApps() { viewModelScope.launch { installedAppsRepository.getAllInstalledApps().collect { installedApps -> val installedMap = installedApps.associateBy { it.repoId } _state.update { current -> current.copy( repos = current.repos .map { homeRepo -> val app = installedMap[homeRepo.repository.id] homeRepo.copy( isInstalled = app != null, isUpdateAvailable = app?.isUpdateAvailable ?: false, ) }.toImmutableList(), isUpdateAvailable = installedMap.any { it.value.isUpdateAvailable }, ) } } } } private fun loadRepos( isInitial: Boolean = false, category: HomeCategory? = null, platform: DiscoveryPlatform? = null, ): Job? { currentJob?.cancel() if (_state.value.isLoading || _state.value.isLoadingMore) { logger.debug("Already loading, skipping...") return null } if (isInitial) { nextPageIndex = 1 } val targetCategory = category ?: _state.value.currentCategory val targetPlatform = platform ?: _state.value.currentPlatform logger.debug("Loading repos: category=$targetCategory, page=$nextPageIndex, isInitial=$isInitial") return viewModelScope .launch { _state.update { it.copy( isLoading = isInitial, isLoadingMore = !isInitial, errorMessage = null, currentCategory = targetCategory, currentPlatform = targetPlatform, repos = if (isInitial) persistentListOf() else it.repos, ) } try { val flow = when (targetCategory) { HomeCategory.TRENDING -> { homeRepository.getTrendingRepositories( platform = targetPlatform, page = nextPageIndex, ) } HomeCategory.HOT_RELEASE -> { homeRepository.getHotReleaseRepositories( platform = targetPlatform, page = nextPageIndex, ) } HomeCategory.MOST_POPULAR -> { homeRepository.getMostPopular( platform = targetPlatform, page = nextPageIndex, ) } } flow.collect { paginatedRepos -> logger.debug( "Received ${paginatedRepos.repos.size} repos, hasMore=${paginatedRepos.hasMore}, nextPage=${paginatedRepos.nextPageIndex}", ) this@HomeViewModel.nextPageIndex = paginatedRepos.nextPageIndex val installedAppsMap = installedAppsRepository .getAllInstalledApps() .first() .associateBy { it.repoId } val favoritesMap = favouritesRepository .getAllFavorites() .first() .associateBy { it.repoId } val starredReposMap = starredRepository .getAllStarred() .first() .associateBy { it.repoId } val seenIds = _state.value.seenRepoIds val newReposWithStatus = paginatedRepos.repos.map { repo -> val app = installedAppsMap[repo.id] val favourite = favoritesMap[repo.id] val starred = starredReposMap[repo.id] DiscoveryRepositoryUi( isInstalled = app != null, isFavourite = favourite != null, isStarred = starred != null, isSeen = repo.id in seenIds, isUpdateAvailable = app?.isUpdateAvailable ?: false, repository = repo.toUi(), ) } _state.update { currentState -> val rawList = currentState.repos + newReposWithStatus val uniqueList = rawList.distinctBy { it.repository.fullName } currentState.copy( repos = uniqueList.toImmutableList(), hasMorePages = paginatedRepos.hasMore, errorMessage = if (uniqueList.isEmpty() && !paginatedRepos.hasMore) { getString(Res.string.no_repositories_found) } else { null }, ) } } logger.debug("Flow completed") _state.update { it.copy(isLoading = false, isLoadingMore = false) } } catch (t: Throwable) { if (t is CancellationException) { logger.debug("Load cancelled (expected)") throw t } logger.error("Load failed: ${t.message}") _state.update { it.copy( isLoading = false, isLoadingMore = false, errorMessage = t.message ?: getString(Res.string.home_failed_to_load_repositories), ) } } }.also { currentJob = it } } fun onAction(action: HomeAction) { when (action) { HomeAction.Refresh -> { viewModelScope.launch { syncInstalledAppsUseCase() nextPageIndex = 1 loadRepos(isInitial = true) } } HomeAction.Retry -> { nextPageIndex = 1 loadRepos(isInitial = true) } HomeAction.LoadMore -> { logger.debug( "LoadMore action: isLoading=${_state.value.isLoading}, isLoadingMore=${_state.value.isLoadingMore}, hasMore=${_state.value.hasMorePages}", ) if (!_state.value.isLoadingMore && !_state.value.isLoading && _state.value.hasMorePages) { loadRepos(isInitial = false) } } is HomeAction.SwitchCategory -> { if (_state.value.currentCategory != action.category) { nextPageIndex = 1 switchCategoryJob?.cancel() switchCategoryJob = viewModelScope.launch { loadRepos(isInitial = true, category = action.category)?.join() ?: return@launch _events.send(HomeEvent.OnScrollToListTop) } } } is HomeAction.OnShareClick -> { viewModelScope.launch { runCatching { shareManager.shareText("https://github-store.org/app?repo=${action.repo.fullName}") }.onFailure { t -> logger.error("Failed to share link: ${t.message}") _events.send( OnMessage(getString(Res.string.failed_to_share_link)), ) return@launch } if (platform != Platform.ANDROID) { _events.send(OnMessage(getString(Res.string.link_copied_to_clipboard))) } } } is HomeAction.SwitchFilterPlatform -> { if (_state.value.currentPlatform != action.platform) { nextPageIndex = 1 switchCategoryJob?.cancel() switchCategoryJob = viewModelScope.launch { loadRepos(isInitial = true, platform = action.platform)?.join() ?: return@launch _events.send(HomeEvent.OnScrollToListTop) } } } HomeAction.OnTogglePlatformPopup -> { _state.update { it.copy( isPlatformPopupVisible = !it.isPlatformPopupVisible, ) } } is HomeAction.OnRepositoryClick -> { // Handled in composable } is HomeAction.OnRepositoryDeveloperClick -> { // Handled in composable } HomeAction.OnSearchClick -> { // Handled in composable } HomeAction.OnSettingsClick -> { // Handled in composable } HomeAction.OnAppsClick -> { // Handled in composable } } } private fun observeLiquidGlassEnabled() { viewModelScope.launch { tweaksRepository.getLiquidGlassEnabled().collect { enabled -> _state.update { it.copy(isLiquidGlassEnabled = enabled) } } } } private fun observeSeenRepos() { viewModelScope.launch { seenReposRepository.getAllSeenRepoIds().collect { ids -> _state.update { current -> current.copy( seenRepoIds = ids, repos = current.repos .map { repo -> repo.copy(isSeen = repo.repository.id in ids) }.toImmutableList(), ) } } } } private fun observeHideSeenEnabled() { viewModelScope.launch { tweaksRepository.getHideSeenEnabled().collect { enabled -> _state.update { it.copy(isHideSeenEnabled = enabled) } } } } private fun observeFavourites() { viewModelScope.launch { favouritesRepository.getAllFavorites().collect { favourites -> val favouritesMap = favourites.associateBy { it.repoId } _state.update { current -> current.copy( repos = current.repos .map { homeRepo -> homeRepo.copy( isFavourite = favouritesMap.containsKey(homeRepo.repository.id), ) }.toImmutableList(), ) } } } } private fun observeStarredRepos() { viewModelScope.launch { starredRepository.getAllStarred().collect { starredRepos -> val starredReposById = starredRepos.associateBy { it.repoId } _state.update { current -> current.copy( repos = current.repos .map { homeRepo -> homeRepo.copy( isStarred = starredReposById.containsKey(homeRepo.repository.id), ) }.toImmutableList(), ) } } } } override fun onCleared() { super.onCleared() currentJob?.cancel() } } ================================================ FILE: feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/HomeFilterChips.kt ================================================ package zed.rainxch.home.presentation.components import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween 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.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text 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.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.luminance import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInParent import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.github.fletchmckee.liquid.liquid import kotlinx.coroutines.launch import zed.rainxch.core.presentation.utils.isLiquidFrostAvailable import zed.rainxch.home.domain.model.HomeCategory import zed.rainxch.home.presentation.locals.LocalHomeTopBarLiquid import zed.rainxch.home.presentation.utils.displayText @Composable fun LiquidGlassCategoryChips( categories: List, selectedCategory: HomeCategory, onCategorySelected: (HomeCategory) -> Unit, isLiquidGlassEnabled: Boolean = true, modifier: Modifier = Modifier, ) { val liquidState = LocalHomeTopBarLiquid.current val density = LocalDensity.current val isDarkTheme = !MaterialTheme.colorScheme.background .luminance() .let { it > 0.5f } val itemPositions = remember { mutableMapOf>() } var selectedItemPos by remember { mutableStateOf?>(null) } val selectedIndex = categories.indexOf(selectedCategory) val rowPaddingDp = 6.dp val rowPaddingPx = with(density) { rowPaddingDp.toPx() } val insetPx = with(density) { 2.dp.toPx() } val indicatorX = remember { Animatable(0f) } val indicatorWidth = remember { Animatable(0f) } LaunchedEffect(selectedIndex, selectedItemPos) { val raw = selectedItemPos ?: itemPositions[selectedIndex] ?: return@LaunchedEffect val targetX = raw.first + rowPaddingPx - insetPx val targetW = raw.second + insetPx * 2f launch { indicatorX.animateTo( targetValue = targetX, animationSpec = spring( dampingRatio = Spring.DampingRatioLowBouncy, stiffness = Spring.StiffnessLow, ), ) } launch { indicatorWidth.animateTo( targetValue = targetW, animationSpec = spring( dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMedium, ), ) } } val glassHighColor = if (isDarkTheme) Color.White.copy(alpha = .14f) else Color.White.copy(alpha = .50f) val glassLowColor = if (isDarkTheme) Color.White.copy(alpha = .05f) else Color.White.copy(alpha = .18f) val specularColor = if (isDarkTheme) Color.White.copy(alpha = .20f) else Color.White.copy(alpha = .55f) val innerGlowColor = if (isDarkTheme) Color.White.copy(alpha = .04f) else Color.White.copy(alpha = .10f) val borderColor = if (isDarkTheme) Color.White.copy(alpha = .10f) else Color.Transparent val containerShape = RoundedCornerShape(20.dp) val useLiquid = isLiquidGlassEnabled && isLiquidFrostAvailable() Box( modifier = modifier .fillMaxWidth() .clip(containerShape) .then( if (useLiquid) { Modifier .background( if (isDarkTheme) { MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = .30f) } else { MaterialTheme.colorScheme.primaryContainer.copy(alpha = .45f) }, ).liquid(liquidState) { this.shape = containerShape this.frost = if (isDarkTheme) 14.dp else 12.dp this.curve = if (isDarkTheme) .30f else .40f this.refraction = if (isDarkTheme) .06f else .10f this.dispersion = if (isDarkTheme) .15f else .22f this.saturation = if (isDarkTheme) .35f else .50f this.contrast = if (isDarkTheme) 1.7f else 1.5f } } else { Modifier .background(MaterialTheme.colorScheme.surfaceContainer) .border( width = 1.dp, color = MaterialTheme.colorScheme.outlineVariant, shape = containerShape, ) }, ), ) { Box( modifier = Modifier .matchParentSize() .drawBehind { if (indicatorWidth.value > 0f) { val pillTop = 5.dp.toPx() val pillHeight = size.height - 10.dp.toPx() val pillCorner = 14.dp.toPx() val pillRadius = CornerRadius(pillCorner) if (isDarkTheme) { drawRoundRect( color = borderColor, topLeft = Offset( indicatorX.value - .5.dp.toPx(), pillTop - .5.dp.toPx(), ), size = Size( indicatorWidth.value + 1.dp.toPx(), pillHeight + 1.dp.toPx(), ), cornerRadius = pillRadius, style = Stroke(width = 1.dp.toPx()), ) } drawRoundRect( brush = Brush.verticalGradient( colors = listOf(glassHighColor, glassLowColor), startY = pillTop, endY = pillTop + pillHeight, ), topLeft = Offset(indicatorX.value, pillTop), size = Size(indicatorWidth.value, pillHeight), cornerRadius = pillRadius, ) val specLeft = indicatorX.value + indicatorWidth.value * .12f val specWidth = indicatorWidth.value * .76f drawRoundRect( brush = Brush.horizontalGradient( colors = listOf( Color.Transparent, specularColor, specularColor.copy(alpha = specularColor.alpha * .6f), Color.Transparent, ), startX = specLeft, endX = specLeft + specWidth, ), topLeft = Offset(specLeft, pillTop + 1.dp.toPx()), size = Size(specWidth, 1.5.dp.toPx()), cornerRadius = CornerRadius(1.dp.toPx()), ) drawRoundRect( brush = Brush.verticalGradient( colors = listOf(Color.Transparent, innerGlowColor), startY = pillTop + pillHeight - 6.dp.toPx(), endY = pillTop + pillHeight, ), topLeft = Offset( indicatorX.value + 6.dp.toPx(), pillTop + pillHeight - 5.dp.toPx(), ), size = Size(indicatorWidth.value - 12.dp.toPx(), 4.dp.toPx()), cornerRadius = CornerRadius(2.dp.toPx()), ) val edgeAlpha = if (isDarkTheme) .06f else .12f drawRoundRect( brush = Brush.horizontalGradient( colors = listOf( Color.White.copy(alpha = edgeAlpha), Color.Transparent, ), startX = indicatorX.value, endX = indicatorX.value + 4.dp.toPx(), ), topLeft = Offset(indicatorX.value, pillTop + 4.dp.toPx()), size = Size(3.dp.toPx(), pillHeight - 8.dp.toPx()), cornerRadius = CornerRadius(1.5.dp.toPx()), ) drawRoundRect( brush = Brush.horizontalGradient( colors = listOf( Color.Transparent, Color.White.copy(alpha = edgeAlpha), ), startX = indicatorX.value + indicatorWidth.value - 4.dp.toPx(), endX = indicatorX.value + indicatorWidth.value, ), topLeft = Offset( indicatorX.value + indicatorWidth.value - 3.dp.toPx(), pillTop + 4.dp.toPx(), ), size = Size(3.dp.toPx(), pillHeight - 8.dp.toPx()), cornerRadius = CornerRadius(1.5.dp.toPx()), ) } }, ) Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = rowPaddingDp, vertical = 3.dp), horizontalArrangement = Arrangement.spacedBy(0.dp), verticalAlignment = Alignment.CenterVertically, ) { categories.forEachIndexed { index, category -> LiquidGlassCategoryChip( category = category, isSelected = category == selectedCategory, onSelect = { onCategorySelected(category) }, modifier = Modifier.weight(1f), onPositioned = { x, width -> itemPositions[index] = x to width if (index == selectedIndex) { selectedItemPos = x to width } if (index == selectedIndex && indicatorWidth.value == 0f) { indicatorX.snapTo(x + rowPaddingPx - insetPx) indicatorWidth.snapTo(width + insetPx * 2f) } }, ) } } } } @Composable private fun LiquidGlassCategoryChip( category: HomeCategory, isSelected: Boolean, onSelect: () -> Unit, modifier: Modifier = Modifier, onPositioned: suspend (x: Float, width: Float) -> Unit, ) { val scope = rememberCoroutineScope() val interactionSource = remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() val pressScale by animateFloatAsState( targetValue = if (isPressed) 0.90f else 1f, animationSpec = spring( dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMedium, ), label = "chipPressScale", ) val selectedAlpha by animateFloatAsState( targetValue = if (isSelected) 1f else 0f, animationSpec = tween(200), label = "selectedAlpha", ) val textColor by animateColorAsState( targetValue = if (isSelected) { MaterialTheme.colorScheme.onSurface } else { MaterialTheme.colorScheme.onSurface.copy(alpha = .65f) }, animationSpec = tween(250), label = "chipTextColor", ) Box( modifier = modifier .clip(CircleShape) .clickable( interactionSource = interactionSource, indication = null, ) { onSelect() } .onGloballyPositioned { coordinates -> val x = coordinates.positionInParent().x val width = coordinates.size.width.toFloat() scope.launch { onPositioned(x, width) } }.graphicsLayer { scaleX = pressScale scaleY = pressScale }.padding(vertical = 8.dp), contentAlignment = Alignment.Center, ) { Box(contentAlignment = Alignment.Center) { Text( text = category.displayText(), style = MaterialTheme.typography.labelLarge.copy( fontWeight = FontWeight.Medium, ), color = textColor, maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center, modifier = Modifier.graphicsLayer { alpha = 1f - selectedAlpha }, ) Text( text = category.displayText(), style = MaterialTheme.typography.labelLarge.copy( fontWeight = FontWeight.Bold, ), color = textColor, maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center, modifier = Modifier.graphicsLayer { alpha = selectedAlpha }, ) } } } ================================================ FILE: feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/locals/LocalHomeTopBarLiquid.kt ================================================ package zed.rainxch.home.presentation.locals import androidx.compose.runtime.compositionLocalOf import io.github.fletchmckee.liquid.LiquidState val LocalHomeTopBarLiquid = compositionLocalOf { error("State isn't initialized!?") } ================================================ FILE: feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/utils/HomeCategoryMapper.kt ================================================ package zed.rainxch.home.presentation.utils import androidx.compose.runtime.Composable import org.jetbrains.compose.resources.stringResource import zed.rainxch.githubstore.core.presentation.res.* import zed.rainxch.home.domain.model.HomeCategory import zed.rainxch.home.domain.model.HomeCategory.* @Composable fun HomeCategory.displayText(): String = when (this) { TRENDING -> stringResource(Res.string.home_category_trending) HOT_RELEASE -> stringResource(Res.string.home_category_hot_release) MOST_POPULAR -> stringResource(Res.string.home_category_most_popular) } ================================================ FILE: feature/profile/CLAUDE.md ================================================ # CLAUDE.md - Profile Feature ## Purpose User profile screen combining account management, appearance settings, network proxy configuration, installer method selection (including Shizuku silent install on Android), and sponsor/about info. Replaces the former `feature/settings/` module. Accessible from the bottom navigation bar. ## Module Structure ``` feature/profile/ ├── domain/ │ ├── model/UserProfile.kt # User profile data model │ └── repository/ProfileRepository.kt # Auth state, user, logout, cache ├── data/ │ ├── di/SharedModule.kt # Koin: profileModule │ ├── repository/ProfileRepositoryImpl.kt # Implementation │ └── mappers/UserProfileMappers.kt # DTO → domain model mappers └── presentation/ ├── ProfileViewModel.kt # State management for profile screen ├── ProfileState.kt # User, theme, proxy, installer, Shizuku status ├── ProfileAction.kt # Theme, logout, proxy, installer, Shizuku actions ├── ProfileEvent.kt # One-off events (navigation, etc.) ├── ProfileRoot.kt # Main composable (LazyColumn of sections) ├── SponsorScreen.kt # Sponsor/donation screen ├── model/ProxyType.kt # NONE, HTTP, SOCKS └── components/ ├── LogoutDialog.kt # Logout confirmation dialog ├── SectionText.kt # Section header text component └── sections/ ├── Account.kt # Login/logout actions ├── AccountSection.kt # Account info display ├── Appearance.kt # Theme color, font, dark mode, AMOLED ├── Installation.kt # Installer type selector (Default/Shizuku) with status ├── Network.kt # Proxy configuration (type, host, port, auth) ├── Options.kt # Favourites, starred, clipboard detection ├── Others.kt # Help, clear cache, version info ├── ProfileSection.kt # User avatar, name, bio └── SettingsSection.kt # Settings group container ``` ## Key Interfaces ```kotlin interface ProfileRepository { val isUserLoggedIn: Flow fun getUser(): Flow fun getVersionName(): String suspend fun logout() fun observeCacheSize(): Flow suspend fun clearCache() } ``` ## State ```kotlin data class ProfileState( val userProfile: UserProfile?, val selectedThemeColor: AppTheme, val selectedFontTheme: FontTheme, val isLogoutDialogVisible: Boolean, val isUserLoggedIn: Boolean, val isAmoledThemeEnabled: Boolean, val isDarkTheme: Boolean?, val versionName: String, val proxyType: ProxyType, val proxyHost: String, val proxyPort: String, val proxyUsername: String, val proxyPassword: String, val isProxyPasswordVisible: Boolean, val autoDetectClipboardLinks: Boolean, val cacheSize: String, val installerType: InstallerType, // DEFAULT or SHIZUKU val shizukuAvailability: ShizukuAvailability // UNAVAILABLE, NOT_RUNNING, PERMISSION_NEEDED, READY ) ``` ## Navigation Routes: - `GithubStoreGraph.ProfileScreen` (data object, no params) — main profile screen - `GithubStoreGraph.SponsorScreen` (data object, no params) — sponsor/donation page ## Implementation Notes - **Installation section** (Android only): Radio-button group to choose between Default (standard system dialog) and Shizuku (silent install). Uses `selectableGroup` + `selectable` with `Role.RadioButton` for accessibility. - **Shizuku status**: Observes `InstallerStatusProvider.shizukuAvailability` flow to show real-time status (not installed, not running, permission needed, ready). Grant permission button calls `InstallerStatusProvider.requestShizukuPermission()`. - **Installer preference** stored via `ThemesRepository.setInstallerType()` / `getInstallerType()` (persisted in DataStore). - **Proxy settings**: Supports HTTP and SOCKS proxies with optional authentication. Saved via `ProxyRepository` from core/domain. - **Appearance**: Theme color (`AppTheme` enum), font (`FontTheme`), dark mode toggle, AMOLED black toggle. - **Account**: Shows GitHub user profile when logged in; login/logout with confirmation dialog. - **Cache management**: Displays cache size and allows clearing. - **BuildKonfig**: Uses `convention.buildkonfig` plugin for build-time configuration. - ViewModel depends on: `ProfileRepository`, `ThemesRepository`, `ProxyRepository`, `InstallerStatusProvider`, `Platform` ================================================ FILE: feature/profile/data/.gitignore ================================================ /build ================================================ FILE: feature/profile/data/build.gradle.kts ================================================ plugins { alias(libs.plugins.convention.kmp.library) alias(libs.plugins.convention.buildkonfig) } kotlin { sourceSets { commonMain { dependencies { implementation(libs.kotlin.stdlib) implementation(projects.core.domain) implementation(projects.core.data) implementation(projects.feature.profile.domain) implementation(libs.bundles.koin.common) implementation(libs.bundles.ktor.common) implementation(libs.kotlinx.coroutines.core) } } androidMain { dependencies { } } jvmMain { dependencies { } } } } ================================================ FILE: feature/profile/data/src/androidMain/AndroidManifest.xml ================================================ ================================================ FILE: feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/di/SharedModule.kt ================================================ package zed.rainxch.profile.data.di import org.koin.dsl.module import zed.rainxch.profile.data.repository.ProfileRepositoryImpl import zed.rainxch.profile.domain.repository.ProfileRepository val settingsModule = module { single { ProfileRepositoryImpl( authenticationState = get(), tokenStore = get(), httpClient = get(), cacheManager = get(), logger = get(), fileLocationsProvider = get(), ) } } ================================================ FILE: feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/mappers/UserProfileMappers.kt ================================================ package zed.rainxch.profile.data.mappers import zed.rainxch.core.data.dto.UserProfileNetwork import zed.rainxch.profile.domain.model.UserProfile fun UserProfileNetwork.toUserProfile(): UserProfile = UserProfile( id = id.toInt(), imageUrl = avatarUrl, name = name ?: login, username = login, bio = bio, repositoryCount = publicRepos, followers = followers, following = following, ) ================================================ FILE: feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/repository/ProfileRepositoryImpl.kt ================================================ package zed.rainxch.profile.data.repository import io.ktor.client.HttpClient import io.ktor.client.request.get import io.ktor.client.request.header import io.ktor.http.HttpHeaders import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import zed.rainxch.core.data.cache.CacheManager import zed.rainxch.core.data.cache.CacheManager.CacheTtl.USER_PROFILE import zed.rainxch.core.data.data_source.TokenStore import zed.rainxch.core.data.dto.UserProfileNetwork import zed.rainxch.core.data.network.executeRequest import zed.rainxch.core.data.services.FileLocationsProvider import zed.rainxch.core.domain.logging.GitHubStoreLogger import zed.rainxch.core.domain.repository.AuthenticationState import zed.rainxch.feature.profile.data.BuildKonfig import zed.rainxch.profile.data.mappers.toUserProfile import zed.rainxch.profile.domain.model.UserProfile import zed.rainxch.profile.domain.repository.ProfileRepository class ProfileRepositoryImpl( private val authenticationState: AuthenticationState, private val tokenStore: TokenStore, private val httpClient: HttpClient, private val cacheManager: CacheManager, private val logger: GitHubStoreLogger, private val fileLocationsProvider: FileLocationsProvider, ) : ProfileRepository { companion object { private const val CACHE_KEY = "profile:me" } override val isUserLoggedIn: Flow get() = authenticationState .isUserLoggedIn() .flowOn(Dispatchers.IO) override fun getUser(): Flow = flow { val token = tokenStore.currentToken() if (token == null) { cacheManager.invalidate(CACHE_KEY) emit(null) return@flow } val cached = cacheManager.get(CACHE_KEY) if (cached != null) { logger.debug("Profile cache hit") emit(cached) return@flow } try { val networkProfile = httpClient .executeRequest { get("/user") { header(HttpHeaders.Accept, "application/vnd.github+json") } }.getOrThrow() val userProfile = networkProfile.toUserProfile() cacheManager.put(CACHE_KEY, userProfile, USER_PROFILE) logger.debug("Fetched and cached user profile: ${userProfile.username}") emit(userProfile) } catch (e: Exception) { logger.error("Failed to fetch user profile: ${e.message}") val stale = cacheManager.getStale(CACHE_KEY) if (stale != null) { logger.debug("Using stale cached profile as fallback") emit(stale) } else { emit(null) } } }.flowOn(Dispatchers.IO) override fun getVersionName(): String = BuildKonfig.VERSION_NAME override suspend fun logout() { tokenStore.clear() cacheManager.clearAll() } override fun observeCacheSize(): Flow = flow { val sizeBytes = fileLocationsProvider.getCacheSizeBytes() emit(sizeBytes) }.flowOn(Dispatchers.IO) override suspend fun clearCache() { fileLocationsProvider.clearCacheFiles() cacheManager.clearAll() logger.debug("Cache cleared successfully") } } ================================================ FILE: feature/profile/domain/.gitignore ================================================ /build ================================================ FILE: feature/profile/domain/build.gradle.kts ================================================ plugins { alias(libs.plugins.convention.kmp.library) } kotlin { sourceSets { commonMain { dependencies { implementation(libs.kotlin.stdlib) implementation(projects.core.domain) implementation(libs.kotlinx.coroutines.core) } } androidMain { dependencies { } } jvmMain { dependencies { } } } } ================================================ FILE: feature/profile/domain/src/androidMain/AndroidManifest.xml ================================================ ================================================ FILE: feature/profile/domain/src/commonMain/kotlin/zed/rainxch/profile/domain/model/UserProfile.kt ================================================ package zed.rainxch.profile.domain.model import kotlinx.serialization.Serializable @Serializable data class UserProfile( val id: Int, val imageUrl: String, val name: String, val username: String, val bio: String?, val repositoryCount: Int, val followers: Int, val following: Int, ) ================================================ FILE: feature/profile/domain/src/commonMain/kotlin/zed/rainxch/profile/domain/repository/ProfileRepository.kt ================================================ package zed.rainxch.profile.domain.repository import kotlinx.coroutines.flow.Flow import zed.rainxch.profile.domain.model.UserProfile interface ProfileRepository { val isUserLoggedIn: Flow fun getUser(): Flow fun getVersionName(): String suspend fun logout() fun observeCacheSize(): Flow suspend fun clearCache() } ================================================ FILE: feature/profile/presentation/.gitignore ================================================ /build ================================================ FILE: feature/profile/presentation/build.gradle.kts ================================================ plugins { alias(libs.plugins.convention.cmp.feature) } kotlin { sourceSets { commonMain { dependencies { implementation(libs.kotlin.stdlib) implementation(projects.core.domain) implementation(projects.core.presentation) implementation(projects.feature.profile.domain) implementation(libs.androidx.compose.ui.tooling.preview) implementation(compose.components.resources) implementation(libs.liquid) } } androidMain { dependencies { } } jvmMain { dependencies { } } } } ================================================ FILE: feature/profile/presentation/src/androidMain/AndroidManifest.xml ================================================ ================================================ FILE: feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileAction.kt ================================================ package zed.rainxch.profile.presentation import zed.rainxch.core.domain.model.AppTheme import zed.rainxch.core.domain.model.FontTheme import zed.rainxch.core.domain.model.InstallerType import zed.rainxch.profile.presentation.model.ProxyType sealed interface ProfileAction { data object OnNavigateBackClick : ProfileAction data class OnThemeColorSelected( val themeColor: AppTheme, ) : ProfileAction data class OnAmoledThemeToggled( val enabled: Boolean, ) : ProfileAction data class OnDarkThemeChange( val isDarkTheme: Boolean?, ) : ProfileAction data object OnLogoutClick : ProfileAction data object OnLogoutConfirmClick : ProfileAction data object OnStarredReposClick : ProfileAction data object OnFavouriteReposClick : ProfileAction data object OnLogoutDismiss : ProfileAction data object OnHelpClick : ProfileAction data object OnLoginClick : ProfileAction data object OnClearCacheClick : ProfileAction data class OnFontThemeSelected( val fontTheme: FontTheme, ) : ProfileAction data class OnProxyTypeSelected( val type: ProxyType, ) : ProfileAction data class OnProxyHostChanged( val host: String, ) : ProfileAction data class OnProxyPortChanged( val port: String, ) : ProfileAction data class OnRepositoriesClick( val username: String, ) : ProfileAction data class OnProxyUsernameChanged( val username: String, ) : ProfileAction data class OnProxyPasswordChanged( val password: String, ) : ProfileAction data object OnProxyPasswordVisibilityToggle : ProfileAction data class OnLiquidGlassEnabledChange( val enabled: Boolean, ) : ProfileAction data object OnProxySave : ProfileAction data class OnInstallerTypeSelected( val type: InstallerType, ) : ProfileAction data object OnRequestShizukuPermission : ProfileAction data class OnAutoUpdateToggled( val enabled: Boolean, ) : ProfileAction data class OnUpdateCheckIntervalChanged( val hours: Long, ) : ProfileAction data class OnIncludePreReleasesToggled( val enabled: Boolean, ) : ProfileAction data class OnAutoDetectClipboardToggled( val enabled: Boolean, ) : ProfileAction data class OnHideSeenToggled( val enabled: Boolean, ) : ProfileAction data object OnClearSeenRepos : ProfileAction data object OnSponsorClick : ProfileAction } ================================================ FILE: feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileEvent.kt ================================================ package zed.rainxch.profile.presentation sealed interface ProfileEvent { data object OnLogoutSuccessful : ProfileEvent data class OnLogoutError( val message: String, ) : ProfileEvent data object OnProxySaved : ProfileEvent data class OnProxySaveError( val message: String, ) : ProfileEvent data object OnCacheCleared : ProfileEvent data class OnCacheClearError( val message: String, ) : ProfileEvent data object OnSeenHistoryCleared : ProfileEvent } ================================================ FILE: feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileRoot.kt ================================================ package zed.rainxch.profile.presentation import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.github.fletchmckee.liquid.liquefiable import kotlinx.coroutines.launch import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import zed.rainxch.core.presentation.locals.LocalBottomNavigationHeight import zed.rainxch.core.presentation.locals.LocalBottomNavigationLiquid import zed.rainxch.core.presentation.theme.GithubStoreTheme import zed.rainxch.core.presentation.utils.ObserveAsEvents import zed.rainxch.githubstore.core.presentation.res.* import zed.rainxch.profile.presentation.components.LogoutDialog import zed.rainxch.profile.presentation.components.sections.about import zed.rainxch.profile.presentation.components.sections.logout import zed.rainxch.profile.presentation.components.sections.othersSection import zed.rainxch.profile.presentation.components.sections.profile import zed.rainxch.profile.presentation.components.sections.settings @Composable fun ProfileRoot( onNavigateBack: () -> Unit, onNavigateToDevProfile: (username: String) -> Unit, onNavigateToAuthentication: () -> Unit, onNavigateToStarredRepos: () -> Unit, onNavigateToFavouriteRepos: () -> Unit, onNavigateToSponsor: () -> Unit, viewModel: ProfileViewModel = koinViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() val snackbarState = remember { SnackbarHostState() } val coroutineScope = rememberCoroutineScope() ObserveAsEvents(viewModel.events) { event -> when (event) { ProfileEvent.OnLogoutSuccessful -> { coroutineScope.launch { snackbarState.showSnackbar(getString(Res.string.logout_success)) onNavigateBack() } } is ProfileEvent.OnLogoutError -> { coroutineScope.launch { snackbarState.showSnackbar(event.message) } } ProfileEvent.OnProxySaved -> { coroutineScope.launch { snackbarState.showSnackbar(getString(Res.string.proxy_saved)) } } is ProfileEvent.OnProxySaveError -> { coroutineScope.launch { snackbarState.showSnackbar(event.message) } } ProfileEvent.OnCacheCleared -> { coroutineScope.launch { snackbarState.showSnackbar(getString(Res.string.cache_cleared)) } } is ProfileEvent.OnCacheClearError -> { coroutineScope.launch { snackbarState.showSnackbar(event.message) } } ProfileEvent.OnSeenHistoryCleared -> { coroutineScope.launch { snackbarState.showSnackbar(getString(Res.string.seen_history_cleared)) } } } } ProfileScreen( state = state, onAction = { action -> when (action) { ProfileAction.OnNavigateBackClick -> { onNavigateBack() } ProfileAction.OnLoginClick -> { onNavigateToAuthentication() } ProfileAction.OnFavouriteReposClick -> { onNavigateToFavouriteRepos() } ProfileAction.OnStarredReposClick -> { onNavigateToStarredRepos() } is ProfileAction.OnRepositoriesClick -> { onNavigateToDevProfile(action.username) } ProfileAction.OnSponsorClick -> { onNavigateToSponsor() } else -> { viewModel.onAction(action) } } }, snackbarState = snackbarState, ) if (state.isLogoutDialogVisible) { LogoutDialog( onDismissRequest = { viewModel.onAction(ProfileAction.OnLogoutDismiss) }, onLogout = { viewModel.onAction(ProfileAction.OnLogoutConfirmClick) }, ) } } @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun ProfileScreen( state: ProfileState, onAction: (ProfileAction) -> Unit, snackbarState: SnackbarHostState, ) { val liquidState = LocalBottomNavigationLiquid.current val bottomNavHeight = LocalBottomNavigationHeight.current Scaffold( snackbarHost = { SnackbarHost( hostState = snackbarState, modifier = Modifier.padding(bottom = bottomNavHeight + 16.dp), ) }, topBar = { TopAppBar() }, containerColor = MaterialTheme.colorScheme.background, modifier = Modifier.then( if (state.isLiquidGlassEnabled) { Modifier.liquefiable(liquidState) } else { Modifier }, ), ) { innerPadding -> LazyColumn( modifier = Modifier .fillMaxSize() .padding(innerPadding) .padding(16.dp), ) { profile( state = state, onAction = onAction, ) item { Spacer(Modifier.height(32.dp)) } settings( state = state, onAction = onAction, ) item { Spacer(Modifier.height(16.dp)) } othersSection( state = state, onAction = onAction, ) item { Spacer(Modifier.height(32.dp)) } about( versionName = state.versionName, onAction = onAction, ) item { Spacer(Modifier.height(32.dp)) } if (state.isUserLoggedIn) { logout( onAction = onAction, ) } item { Spacer(Modifier.height(bottomNavHeight + 32.dp)) } } } } @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable private fun TopAppBar() { TopAppBar( title = { Text( text = stringResource(Res.string.profile_title), style = MaterialTheme.typography.titleMediumEmphasized, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface, ) }, ) } @Preview @Composable private fun Preview() { GithubStoreTheme { ProfileScreen( state = ProfileState(), onAction = {}, snackbarState = SnackbarHostState(), ) } } ================================================ FILE: feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileState.kt ================================================ package zed.rainxch.profile.presentation import zed.rainxch.core.domain.model.AppTheme import zed.rainxch.core.domain.model.FontTheme import zed.rainxch.core.domain.model.InstallerType import zed.rainxch.core.domain.model.ShizukuAvailability import zed.rainxch.profile.domain.model.UserProfile import zed.rainxch.profile.presentation.model.ProxyType data class ProfileState( val userProfile: UserProfile? = null, val selectedThemeColor: AppTheme = AppTheme.OCEAN, val selectedFontTheme: FontTheme = FontTheme.CUSTOM, val isLogoutDialogVisible: Boolean = false, val isUserLoggedIn: Boolean = false, val isAmoledThemeEnabled: Boolean = false, val isDarkTheme: Boolean? = null, val versionName: String = "", val proxyType: ProxyType = ProxyType.NONE, val proxyHost: String = "", val proxyPort: String = "", val proxyUsername: String = "", val proxyPassword: String = "", val isProxyPasswordVisible: Boolean = false, val autoDetectClipboardLinks: Boolean = true, val cacheSize: String = "", val installerType: InstallerType = InstallerType.DEFAULT, val shizukuAvailability: ShizukuAvailability = ShizukuAvailability.UNAVAILABLE, val autoUpdateEnabled: Boolean = false, val updateCheckIntervalHours: Long = 6L, val includePreReleases: Boolean = false, val isLiquidGlassEnabled: Boolean = true, val isHideSeenEnabled: Boolean = false, ) ================================================ FILE: feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileViewModel.kt ================================================ package zed.rainxch.profile.presentation import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.jetbrains.compose.resources.getString import zed.rainxch.core.domain.model.ProxyConfig import zed.rainxch.core.domain.repository.ProxyRepository import zed.rainxch.core.domain.repository.SeenReposRepository import zed.rainxch.core.domain.repository.TweaksRepository import zed.rainxch.core.domain.system.InstallerStatusProvider import zed.rainxch.core.domain.system.UpdateScheduleManager import zed.rainxch.core.domain.utils.BrowserHelper import zed.rainxch.githubstore.core.presentation.res.Res import zed.rainxch.githubstore.core.presentation.res.failed_to_save_proxy_settings import zed.rainxch.githubstore.core.presentation.res.invalid_proxy_port import zed.rainxch.githubstore.core.presentation.res.proxy_host_required import zed.rainxch.profile.domain.repository.ProfileRepository import zed.rainxch.profile.presentation.model.ProxyType class ProfileViewModel( private val browserHelper: BrowserHelper, private val tweaksRepository: TweaksRepository, private val profileRepository: ProfileRepository, private val installerStatusProvider: InstallerStatusProvider, private val proxyRepository: ProxyRepository, private val updateScheduleManager: UpdateScheduleManager, private val seenReposRepository: SeenReposRepository, ) : ViewModel() { private var userProfileJob: Job? = null private var hasLoadedInitialData = false private val _state = MutableStateFlow(ProfileState()) val state = _state .onStart { if (!hasLoadedInitialData) { loadCurrentTheme() loadUserProfile() loadVersionName() loadProxyConfig() loadInstallerPreference() loadAutoUpdatePreference() loadUpdateCheckInterval() loadIncludePreReleases() loadLiquidGlassEnabled() loadHideSeenEnabled() observeLoggedInStatus() observeCacheSize() observeShizukuStatus() hasLoadedInitialData = true } }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000L), initialValue = ProfileState(), ) private val _events = Channel() val events = _events.receiveAsFlow() private fun observeCacheSize() { viewModelScope.launch { profileRepository.observeCacheSize().collect { sizeBytes -> _state.update { it.copy(cacheSize = formatCacheSize(sizeBytes)) } } } } private fun formatCacheSize(bytes: Long): String { if (bytes <= 0) return "0 B" val units = arrayOf("B", "KB", "MB", "GB") var size = bytes.toDouble() var unitIndex = 0 while (size >= 1024 && unitIndex < units.lastIndex) { size /= 1024 unitIndex++ } return if (size == size.toLong().toDouble()) { "${size.toLong()} ${units[unitIndex]}" } else { "${"%.1f".format(size)} ${units[unitIndex]}" } } private fun loadVersionName() { viewModelScope.launch { _state.update { it.copy( versionName = profileRepository.getVersionName(), ) } } } private fun observeLoggedInStatus() { viewModelScope.launch { profileRepository.isUserLoggedIn .collect { isLoggedIn -> _state.update { it.copy(isUserLoggedIn = isLoggedIn) } if (isLoggedIn) { loadUserProfile() } else { _state.update { it.copy(userProfile = null) } } } } } private fun loadUserProfile() { userProfileJob?.cancel() userProfileJob = viewModelScope.launch { profileRepository.getUser().collect { profile -> _state.update { it.copy(userProfile = profile) } } } } private fun loadCurrentTheme() { viewModelScope.launch { tweaksRepository.getThemeColor().collect { theme -> _state.update { it.copy(selectedThemeColor = theme) } } } viewModelScope.launch { tweaksRepository.getAmoledTheme().collect { isAmoled -> _state.update { it.copy(isAmoledThemeEnabled = isAmoled) } } } viewModelScope.launch { tweaksRepository.getIsDarkTheme().collect { isDarkTheme -> _state.update { it.copy(isDarkTheme = isDarkTheme) } } } viewModelScope.launch { tweaksRepository.getFontTheme().collect { fontTheme -> _state.update { it.copy(selectedFontTheme = fontTheme) } } } viewModelScope.launch { tweaksRepository.getAutoDetectClipboardLinks().collect { enabled -> _state.update { it.copy(autoDetectClipboardLinks = enabled) } } } } private fun loadProxyConfig() { viewModelScope.launch { proxyRepository.getProxyConfig().collect { config -> _state.update { it.copy( proxyType = ProxyType.fromConfig(config), proxyHost = when (config) { is ProxyConfig.Http -> config.host is ProxyConfig.Socks -> config.host else -> it.proxyHost }, proxyPort = when (config) { is ProxyConfig.Http -> config.port.toString() is ProxyConfig.Socks -> config.port.toString() else -> it.proxyPort }, proxyUsername = when (config) { is ProxyConfig.Http -> config.username ?: "" is ProxyConfig.Socks -> config.username ?: "" else -> it.proxyUsername }, proxyPassword = when (config) { is ProxyConfig.Http -> config.password ?: "" is ProxyConfig.Socks -> config.password ?: "" else -> it.proxyPassword }, ) } } } } private fun loadInstallerPreference() { viewModelScope.launch { tweaksRepository.getInstallerType().collect { type -> _state.update { it.copy(installerType = type) } } } } private fun observeShizukuStatus() { viewModelScope.launch { installerStatusProvider.shizukuAvailability.collect { availability -> _state.update { it.copy(shizukuAvailability = availability) } } } } private fun loadAutoUpdatePreference() { viewModelScope.launch { tweaksRepository.getAutoUpdateEnabled().collect { enabled -> _state.update { it.copy(autoUpdateEnabled = enabled) } } } } private fun loadUpdateCheckInterval() { viewModelScope.launch { tweaksRepository.getUpdateCheckInterval().collect { hours -> _state.update { it.copy(updateCheckIntervalHours = hours) } } } } private fun loadLiquidGlassEnabled() { viewModelScope.launch { tweaksRepository.getLiquidGlassEnabled().collect { enabled -> _state.update { it.copy(isLiquidGlassEnabled = enabled) } } } } private fun loadHideSeenEnabled() { viewModelScope.launch { tweaksRepository.getHideSeenEnabled().collect { enabled -> _state.update { it.copy(isHideSeenEnabled = enabled) } } } } private fun loadIncludePreReleases() { viewModelScope.launch { tweaksRepository.getIncludePreReleases().collect { enabled -> _state.update { it.copy(includePreReleases = enabled) } } } } fun onAction(action: ProfileAction) { when (action) { ProfileAction.OnHelpClick -> { browserHelper.openUrl( url = "https://github.com/OpenHub-Store/GitHub-Store/issues", ) } ProfileAction.OnClearCacheClick -> { viewModelScope.launch { runCatching { profileRepository.clearCache() }.onSuccess { observeCacheSize() _events.send(ProfileEvent.OnCacheCleared) }.onFailure { error -> _events.send( ProfileEvent.OnCacheClearError( error.message ?: "Failed to clear cache", ), ) } } } is ProfileAction.OnThemeColorSelected -> { viewModelScope.launch { tweaksRepository.setThemeColor(action.themeColor) } } is ProfileAction.OnAmoledThemeToggled -> { viewModelScope.launch { tweaksRepository.setAmoledTheme(action.enabled) } } ProfileAction.OnLogoutClick -> { _state.update { it.copy( isLogoutDialogVisible = true, ) } } ProfileAction.OnLogoutConfirmClick -> { viewModelScope.launch { runCatching { profileRepository.logout() }.onSuccess { _state.update { it.copy(isLogoutDialogVisible = false, userProfile = null) } _events.send(ProfileEvent.OnLogoutSuccessful) }.onFailure { error -> _state.update { it.copy(isLogoutDialogVisible = false) } error.message?.let { _events.send(ProfileEvent.OnLogoutError(it)) } } } } ProfileAction.OnLogoutDismiss -> { _state.update { it.copy( isLogoutDialogVisible = false, ) } } is ProfileAction.OnLiquidGlassEnabledChange -> { viewModelScope.launch { tweaksRepository.setLiquidGlassEnabled(action.enabled) } } ProfileAction.OnNavigateBackClick -> { // Handed in composable } ProfileAction.OnLoginClick -> { // Handed in composable } ProfileAction.OnFavouriteReposClick -> { // Handed in composable } ProfileAction.OnStarredReposClick -> { // Handed in composable } is ProfileAction.OnRepositoriesClick -> { // Handed in composable } ProfileAction.OnSponsorClick -> { // Handed in composable } is ProfileAction.OnFontThemeSelected -> { viewModelScope.launch { tweaksRepository.setFontTheme(action.fontTheme) } } is ProfileAction.OnDarkThemeChange -> { viewModelScope.launch { tweaksRepository.setDarkTheme(action.isDarkTheme) } } is ProfileAction.OnProxyTypeSelected -> { _state.update { it.copy(proxyType = action.type) } if (action.type == ProxyType.NONE || action.type == ProxyType.SYSTEM) { val config = when (action.type) { ProxyType.NONE -> ProxyConfig.None ProxyType.SYSTEM -> ProxyConfig.System else -> return } viewModelScope.launch { runCatching { proxyRepository.setProxyConfig(config) }.onSuccess { _events.send(ProfileEvent.OnProxySaved) }.onFailure { error -> _events.send( ProfileEvent.OnProxySaveError( error.message ?: getString(Res.string.failed_to_save_proxy_settings), ), ) } } } } is ProfileAction.OnProxyHostChanged -> { _state.update { it.copy(proxyHost = action.host) } } is ProfileAction.OnProxyPortChanged -> { _state.update { it.copy(proxyPort = action.port) } } is ProfileAction.OnProxyUsernameChanged -> { _state.update { it.copy(proxyUsername = action.username) } } is ProfileAction.OnProxyPasswordChanged -> { _state.update { it.copy(proxyPassword = action.password) } } ProfileAction.OnProxyPasswordVisibilityToggle -> { _state.update { it.copy(isProxyPasswordVisible = !it.isProxyPasswordVisible) } } is ProfileAction.OnAutoDetectClipboardToggled -> { viewModelScope.launch { tweaksRepository.setAutoDetectClipboardLinks(action.enabled) } } is ProfileAction.OnHideSeenToggled -> { viewModelScope.launch { tweaksRepository.setHideSeenEnabled(action.enabled) } } ProfileAction.OnClearSeenRepos -> { viewModelScope.launch { seenReposRepository.clearAll() _events.send(ProfileEvent.OnSeenHistoryCleared) } } is ProfileAction.OnInstallerTypeSelected -> { viewModelScope.launch { tweaksRepository.setInstallerType(action.type) } } ProfileAction.OnRequestShizukuPermission -> { installerStatusProvider.requestShizukuPermission() } is ProfileAction.OnAutoUpdateToggled -> { viewModelScope.launch { tweaksRepository.setAutoUpdateEnabled(action.enabled) } } is ProfileAction.OnUpdateCheckIntervalChanged -> { viewModelScope.launch { tweaksRepository.setUpdateCheckInterval(action.hours) updateScheduleManager.reschedule(action.hours) } } is ProfileAction.OnIncludePreReleasesToggled -> { viewModelScope.launch { tweaksRepository.setIncludePreReleases(action.enabled) } } ProfileAction.OnProxySave -> { val currentState = _state.value val port = currentState.proxyPort .toIntOrNull() ?.takeIf { it in 1..65535 } ?: run { viewModelScope.launch { _events.send(ProfileEvent.OnProxySaveError(getString(Res.string.invalid_proxy_port))) } return } val host = currentState.proxyHost.trim().takeIf { it.isNotBlank() } ?: run { viewModelScope.launch { _events.send(ProfileEvent.OnProxySaveError(getString(Res.string.proxy_host_required))) } return } val username = currentState.proxyUsername.takeIf { it.isNotBlank() } val password = currentState.proxyPassword.takeIf { it.isNotBlank() } val config = when (currentState.proxyType) { ProxyType.HTTP -> ProxyConfig.Http(host, port, username, password) ProxyType.SOCKS -> ProxyConfig.Socks(host, port, username, password) ProxyType.NONE -> ProxyConfig.None ProxyType.SYSTEM -> ProxyConfig.System } viewModelScope.launch { runCatching { proxyRepository.setProxyConfig(config) }.onSuccess { _events.send(ProfileEvent.OnProxySaved) }.onFailure { error -> _events.send( ProfileEvent.OnProxySaveError( error.message ?: getString(Res.string.failed_to_save_proxy_settings), ), ) } } } } } } ================================================ FILE: feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/SponsorScreen.kt ================================================ package zed.rainxch.profile.presentation import androidx.compose.foundation.background 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.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.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.Coffee import androidx.compose.material.icons.filled.EmojiEvents import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.IosShare import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.VolunteerActivism import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ElevatedButton import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar 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.Brush import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import zed.rainxch.githubstore.core.presentation.res.* @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun SponsorScreen(onNavigateBack: () -> Unit) { val uriHandler = LocalUriHandler.current val onOpenUrl: (String) -> Unit = { url -> runCatching { uriHandler.openUri(url) } } Scaffold( topBar = { TopAppBar( title = { Text( text = stringResource(Res.string.sponsor_title), style = MaterialTheme.typography.titleMediumEmphasized, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface, ) }, navigationIcon = { IconButton(onClick = onNavigateBack) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.navigate_back), ) } }, ) }, containerColor = MaterialTheme.colorScheme.background, ) { innerPadding -> Column( modifier = Modifier .fillMaxSize() .padding(innerPadding) .verticalScroll(rememberScrollState()) .padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { // Hero section HeroSection() // Golden Kodee voting CTA - the highlight GoldenKodeeCard( onRegisterClick = { onOpenUrl("https://golden-kodee.awardsplatform.com/") }, onVoteClick = { onOpenUrl("https://golden-kodee.awardsplatform.com/entry/vote/mNKjQxkX") }, ) // Financial support options SponsorOptionCard( icon = Icons.Filled.Favorite, title = stringResource(Res.string.sponsor_github_sponsors), description = stringResource(Res.string.sponsor_github_sponsors_desc), onClick = { onOpenUrl("https://github.com/sponsors/rainxchzed") }, ) SponsorOptionCard( icon = Icons.Filled.Coffee, title = stringResource(Res.string.sponsor_buy_me_coffee), description = stringResource(Res.string.sponsor_buy_me_coffee_desc), onClick = { onOpenUrl("https://buymeacoffee.com/rainxchzed") }, ) Spacer(Modifier.height(8.dp)) // Other ways to help OtherWaysSection(onOpenUrl = onOpenUrl) // Thank you note Text( text = stringResource(Res.string.sponsor_thank_you), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp), ) Spacer(Modifier.height(16.dp)) } } } @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun HeroSection() { Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, ) { Icon( imageVector = Icons.Filled.VolunteerActivism, contentDescription = null, modifier = Modifier .size(64.dp) .clip(CircleShape) .background( Brush.linearGradient( listOf( MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.tertiary, ), ), ).padding(14.dp), tint = MaterialTheme.colorScheme.onPrimary, ) Spacer(Modifier.height(16.dp)) Text( text = stringResource(Res.string.sponsor_hero_title), style = MaterialTheme.typography.headlineSmallEmphasized, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onBackground, textAlign = TextAlign.Center, ) Spacer(Modifier.height(8.dp)) Text( text = stringResource(Res.string.sponsor_hero_subtitle), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, modifier = Modifier.padding(horizontal = 8.dp), ) Spacer(Modifier.height(8.dp)) Text( text = stringResource(Res.string.sponsor_personal_note), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, modifier = Modifier.padding(horizontal = 8.dp), ) } } @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun GoldenKodeeCard( onRegisterClick: () -> Unit, onVoteClick: () -> Unit, ) { Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.primaryContainer, ), shape = RoundedCornerShape(28.dp), ) { Column( modifier = Modifier .fillMaxWidth() .padding(20.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { Icon( imageVector = Icons.Filled.EmojiEvents, contentDescription = null, modifier = Modifier.size(48.dp), tint = MaterialTheme.colorScheme.onPrimaryContainer, ) Spacer(Modifier.height(12.dp)) Text( text = stringResource(Res.string.sponsor_kodee_title), style = MaterialTheme.typography.titleLargeEmphasized, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onPrimaryContainer, textAlign = TextAlign.Center, ) Spacer(Modifier.height(8.dp)) Text( text = stringResource(Res.string.sponsor_kodee_subtitle), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f), textAlign = TextAlign.Center, ) Spacer(Modifier.height(16.dp)) // Steps Column( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(16.dp)) .background(MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.08f)) .padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp), ) { Text( text = stringResource(Res.string.sponsor_kodee_step1), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f), ) Text( text = stringResource(Res.string.sponsor_kodee_step2), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f), ) Text( text = stringResource(Res.string.sponsor_kodee_step3), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f), ) } Spacer(Modifier.height(16.dp)) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { FilledTonalButton( onClick = onRegisterClick, modifier = Modifier.weight(1f), shape = RoundedCornerShape(16.dp), colors = ButtonDefaults.filledTonalButtonColors( containerColor = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.15f), contentColor = MaterialTheme.colorScheme.onPrimaryContainer, ), ) { Text( text = stringResource(Res.string.sponsor_kodee_register), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, ) } ElevatedButton( onClick = onVoteClick, modifier = Modifier.weight(1f), shape = RoundedCornerShape(16.dp), colors = ButtonDefaults.elevatedButtonColors( containerColor = MaterialTheme.colorScheme.primary, contentColor = MaterialTheme.colorScheme.onPrimary, ), ) { Icon( imageVector = Icons.Filled.EmojiEvents, contentDescription = null, modifier = Modifier.size(18.dp), ) Spacer(Modifier.width(6.dp)) Text( text = stringResource(Res.string.sponsor_kodee_vote), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, ) } } Spacer(Modifier.height(8.dp)) Text( text = stringResource(Res.string.sponsor_kodee_deadline), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.6f), fontWeight = FontWeight.Medium, ) } } } @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun SponsorOptionCard( icon: ImageVector, title: String, description: String, onClick: () -> Unit, modifier: Modifier = Modifier, ) { ElevatedCard( modifier = modifier.fillMaxWidth(), onClick = onClick, colors = CardDefaults.elevatedCardColors( containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, ), shape = RoundedCornerShape(24.dp), ) { Row( modifier = Modifier .fillMaxWidth() .padding(16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp), ) { Icon( imageVector = icon, contentDescription = null, modifier = Modifier .size(44.dp) .clip(CircleShape) .background( Brush.linearGradient( listOf( MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.secondary, ), ), ).padding(10.dp), tint = MaterialTheme.colorScheme.onPrimary, ) Column(modifier = Modifier.weight(1f)) { Text( text = title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface, ) Text( text = description, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } } @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun OtherWaysSection(onOpenUrl: (String) -> Unit) { Column( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp), ) { Text( text = stringResource(Res.string.sponsor_other_ways_title), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.secondary, fontWeight = FontWeight.Bold, modifier = Modifier.padding(start = 8.dp), ) Spacer(Modifier.height(4.dp)) ElevatedCard( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.elevatedCardColors( containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, ), shape = RoundedCornerShape(24.dp), ) { Column(modifier = Modifier.padding(4.dp)) { OtherWayItem( icon = Icons.Filled.Star, title = stringResource(Res.string.sponsor_star_repo), description = stringResource(Res.string.sponsor_star_repo_desc), onClick = { onOpenUrl("https://github.com/OpenHub-Store/GitHub-Store") }, ) OtherWayItem( icon = Icons.Filled.BugReport, title = stringResource(Res.string.sponsor_report_bugs), description = stringResource(Res.string.sponsor_report_bugs_desc), onClick = { onOpenUrl("https://github.com/OpenHub-Store/GitHub-Store/issues") }, ) OtherWayItem( icon = Icons.Filled.IosShare, title = stringResource(Res.string.sponsor_share), description = stringResource(Res.string.sponsor_share_desc), onClick = { onOpenUrl("https://github.com/OpenHub-Store/GitHub-Store") }, ) } } } } @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun OtherWayItem( icon: ImageVector, title: String, description: String, onClick: () -> Unit, ) { FilledTonalButton( onClick = onClick, modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(20.dp), colors = ButtonDefaults.filledTonalButtonColors( containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, contentColor = MaterialTheme.colorScheme.onSurface, ), ) { Row( modifier = Modifier .fillMaxWidth() .padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { Icon( imageVector = icon, contentDescription = null, modifier = Modifier.size(24.dp), tint = MaterialTheme.colorScheme.primary, ) Column(modifier = Modifier.weight(1f)) { Text( text = title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface, ) Text( text = description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } } ================================================ FILE: feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/LogoutDialog.kt ================================================ package zed.rainxch.profile.presentation.components import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton 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.unit.dp import androidx.compose.ui.window.DialogProperties import org.jetbrains.compose.resources.stringResource import zed.rainxch.githubstore.core.presentation.res.* @OptIn(ExperimentalMaterial3Api::class) @Composable fun LogoutDialog( onDismissRequest: () -> Unit, onLogout: () -> Unit, modifier: Modifier = Modifier, ) { BasicAlertDialog( onDismissRequest = onDismissRequest, properties = DialogProperties( dismissOnClickOutside = false, usePlatformDefaultWidth = false, ), modifier = modifier .padding(16.dp) .clip(RoundedCornerShape(24.dp)) .background(MaterialTheme.colorScheme.surfaceContainerHigh) .padding(16.dp), ) { Column( verticalArrangement = Arrangement.spacedBy(8.dp), ) { Text( text = stringResource(Res.string.warning), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Bold, ) Text( text = stringResource(Res.string.logout_confirmation), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( text = stringResource(Res.string.logout_revocation_note), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline, ) Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), ) { TextButton( onClick = { onDismissRequest() }, ) { Text( text = stringResource(Res.string.close), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, ) } Button( onClick = onLogout, colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.errorContainer, contentColor = MaterialTheme.colorScheme.onErrorContainer, ), ) { Text( text = stringResource(Res.string.logout), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, ) } } } } } ================================================ FILE: feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/SectionText.kt ================================================ package zed.rainxch.profile.presentation.components import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun SectionTitle(text: String) { Text( text = text, style = MaterialTheme.typography.titleMediumEmphasized, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold, modifier = Modifier.padding(start = 8.dp), ) } @Composable fun SectionHeader(text: String) { Text( text = text, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.secondary, fontWeight = FontWeight.Bold, modifier = Modifier.padding(start = 8.dp), ) } ================================================ FILE: feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/About.kt ================================================ package zed.rainxch.profile.presentation.components.sections import androidx.compose.foundation.layout.Arrangement 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.lazy.LazyListScope import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.QuestionMark import androidx.compose.material3.CardDefaults import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import zed.rainxch.githubstore.core.presentation.res.* import zed.rainxch.profile.presentation.ProfileAction import zed.rainxch.profile.presentation.components.SectionHeader @OptIn(ExperimentalMaterial3ExpressiveApi::class) fun LazyListScope.about( versionName: String, onAction: (ProfileAction) -> Unit, ) { item { SectionHeader( text = stringResource(Res.string.section_about), ) Spacer(Modifier.height(8.dp)) ElevatedCard( modifier = Modifier .fillMaxWidth() .padding(4.dp), colors = CardDefaults.elevatedCardColors( containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, ), shape = RoundedCornerShape(32.dp), ) { AboutItem( icon = Icons.Filled.Info, title = stringResource(Res.string.version), actions = { Text( text = versionName, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.outline, modifier = Modifier.padding(horizontal = 8.dp), ) }, ) HorizontalDivider() AboutItem( icon = Icons.Filled.QuestionMark, title = stringResource(Res.string.help_support), actions = { IconButton( shape = IconButtonDefaults.shapes().shape, onClick = { onAction(ProfileAction.OnHelpClick) }, colors = IconButtonDefaults.iconButtonColors( contentColor = MaterialTheme.colorScheme.onSurface, ), ) { Icon( imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, modifier = Modifier.size(24.dp), ) } }, ) } } } @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun AboutItem( icon: ImageVector, title: String, actions: @Composable () -> Unit, modifier: Modifier = Modifier, ) { Row( modifier = modifier .fillMaxWidth() .padding(8.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, ) { IconButton( shapes = IconButtonDefaults.shapes(), onClick = { }, colors = IconButtonDefaults.iconButtonColors( containerColor = MaterialTheme.colorScheme.secondaryContainer, contentColor = MaterialTheme.colorScheme.onSecondaryContainer, ), ) { Icon( imageVector = icon, contentDescription = null, modifier = Modifier.size(24.dp), ) } Text( text = title, color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f), ) actions.invoke() } } ================================================ FILE: feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Account.kt ================================================ package zed.rainxch.profile.presentation.components.sections import androidx.compose.foundation.layout.Arrangement 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.lazy.LazyListScope import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.automirrored.filled.Logout import androidx.compose.material3.CardDefaults import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import zed.rainxch.githubstore.core.presentation.res.* import zed.rainxch.profile.presentation.ProfileAction @OptIn(ExperimentalMaterial3ExpressiveApi::class) fun LazyListScope.logout(onAction: (ProfileAction) -> Unit) { item { Spacer(Modifier.height(8.dp)) ElevatedCard( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.elevatedCardColors( containerColor = MaterialTheme.colorScheme.errorContainer, ), shape = RoundedCornerShape(32.dp), onClick = { onAction(ProfileAction.OnLogoutClick) }, ) { AccountItem( icon = Icons.AutoMirrored.Filled.Logout, title = stringResource(Res.string.logout), actions = { IconButton( onClick = { onAction(ProfileAction.OnLogoutClick) }, colors = IconButtonDefaults.iconButtonColors( contentColor = MaterialTheme.colorScheme.onSurface, ), ) { Icon( imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, modifier = Modifier.size(24.dp), ) } }, ) } } } @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun AccountItem( icon: ImageVector, title: String, actions: @Composable () -> Unit, modifier: Modifier = Modifier, ) { Row( modifier = modifier .fillMaxWidth() .padding(8.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, ) { IconButton( shapes = IconButtonDefaults.shapes(), onClick = { }, colors = IconButtonDefaults.iconButtonColors( containerColor = MaterialTheme.colorScheme.errorContainer, contentColor = MaterialTheme.colorScheme.onErrorContainer, ), ) { Icon( imageVector = icon, contentDescription = null, modifier = Modifier.size(24.dp), ) } Text( text = title, color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f), ) actions.invoke() } } ================================================ FILE: feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/AccountSection.kt ================================================ package zed.rainxch.profile.presentation.components.sections import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background 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.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.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.outlined.AccountCircle import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text 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.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import zed.rainxch.core.presentation.components.GitHubStoreImage import zed.rainxch.core.presentation.components.GithubStoreButton import zed.rainxch.core.presentation.theme.GithubStoreTheme import zed.rainxch.githubstore.core.presentation.res.* import zed.rainxch.profile.domain.model.UserProfile import zed.rainxch.profile.presentation.ProfileAction import zed.rainxch.profile.presentation.ProfileState @OptIn(ExperimentalMaterial3ExpressiveApi::class) fun LazyListScope.accountSection( state: ProfileState, onAction: (ProfileAction) -> Unit, ) { item { Column( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { if (state.userProfile == null) { Icon( imageVector = Icons.Filled.AccountCircle, contentDescription = null, modifier = Modifier .size(100.dp) .clip(CircleShape) .background(MaterialTheme.colorScheme.surfaceContainerHigh) .padding(20.dp), tint = MaterialTheme.colorScheme.onSurface, ) } else { GitHubStoreImage( imageModel = { state.userProfile.imageUrl }, modifier = Modifier .size(128.dp) .clip(CircleShape) .background(MaterialTheme.colorScheme.surfaceContainerHigh), ) Spacer(Modifier.height(8.dp)) } if (state.userProfile != null) { val displayName = state.userProfile.name.takeIf { it.isNotBlank() } ?: state.userProfile.username Text( text = displayName, style = MaterialTheme.typography.titleLargeEmphasized, color = MaterialTheme.colorScheme.onBackground, textAlign = TextAlign.Center, ) Text( text = "@${state.userProfile.username}", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, ) state.userProfile.bio?.let { bio -> Text( text = bio, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, ) } } else { Spacer(Modifier.height(8.dp)) Text( text = stringResource(Res.string.profile_sign_in_title), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onBackground, textAlign = TextAlign.Center, ) Spacer(Modifier.height(4.dp)) Text( text = stringResource(Res.string.profile_sign_in_description), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, ) } if (state.userProfile != null) { Spacer(Modifier.height(16.dp)) Row( modifier = Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { StatCard( label = stringResource(Res.string.profile_repos), value = state.userProfile.repositoryCount.toString(), modifier = Modifier.weight(1f), onClick = { onAction(ProfileAction.OnRepositoriesClick(state.userProfile.username)) }, ) StatCard( label = stringResource(Res.string.followers), value = state.userProfile.followers.toString(), modifier = Modifier.weight(1f), ) StatCard( label = stringResource(Res.string.following), value = state.userProfile.following.toString(), modifier = Modifier.weight(1f), ) } } if (state.userProfile == null) { Spacer(Modifier.height(8.dp)) GithubStoreButton( text = stringResource(Res.string.profile_login), onClick = { onAction(ProfileAction.OnLoginClick) }, modifier = Modifier .fillMaxWidth() .padding(horizontal = 8.dp), ) } } } } @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun StatCard( label: String, value: String, modifier: Modifier = Modifier, onClick: (() -> Unit)? = null, ) { Card( modifier = modifier, colors = CardDefaults.elevatedCardColors( containerColor = MaterialTheme.colorScheme.surfaceContainerLow, contentColor = MaterialTheme.colorScheme.onSurface, ), shape = RoundedCornerShape(32.dp), border = BorderStroke( width = 1.dp, color = MaterialTheme.colorScheme.secondary, ), onClick = { onClick?.invoke() }, ) { Column( modifier = Modifier .fillMaxWidth() .padding(12.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { Text( text = value, maxLines = 1, style = MaterialTheme.typography.titleLargeEmphasized, overflow = TextOverflow.Ellipsis, color = MaterialTheme.colorScheme.onSurface, ) Text( text = label, maxLines = 1, style = MaterialTheme.typography.bodyLargeEmphasized, overflow = TextOverflow.Ellipsis, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } @Preview(showBackground = true) @Composable fun AccountSectionPreview() { GithubStoreTheme { LazyColumn { accountSection( state = ProfileState(), onAction = { }, ) } } } @Preview(showBackground = true) @Composable fun AccountSectionUserPreview() { GithubStoreTheme { LazyColumn { accountSection( state = ProfileState( userProfile = UserProfile( id = 1, imageUrl = "", name = "Octocat", username = "the_octocat", bio = " Language Savant. If your repository's language is being reported incorrectly, send us a pull request! ", repositoryCount = 8, followers = 21900, following = 9, ), ), onAction = { }, ) } } } ================================================ FILE: feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Appearance.kt ================================================ package zed.rainxch.profile.presentation.components.sections import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut 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.isSystemInDarkTheme 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.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Colorize import androidx.compose.material.icons.filled.DarkMode import androidx.compose.material.icons.filled.Done import androidx.compose.material.icons.filled.LightMode import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.MaterialShapes import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.ripple import androidx.compose.material3.toShape 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.draw.scale import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import zed.rainxch.core.domain.model.AppTheme import zed.rainxch.core.domain.model.FontTheme import zed.rainxch.core.presentation.components.ExpressiveCard import zed.rainxch.core.presentation.theme.isDynamicColorAvailable import zed.rainxch.core.presentation.utils.displayName import zed.rainxch.core.presentation.utils.primaryColor import zed.rainxch.githubstore.core.presentation.res.* import zed.rainxch.profile.presentation.ProfileAction import zed.rainxch.profile.presentation.ProfileState import zed.rainxch.profile.presentation.components.SectionHeader @OptIn(ExperimentalMaterial3ExpressiveApi::class) fun LazyListScope.appearanceSection( state: ProfileState, onAction: (ProfileAction) -> Unit, ) { item { SectionHeader( text = stringResource(Res.string.section_appearance), ) VerticalSpacer(8.dp) ThemeSelectionCard( isDarkTheme = state.isDarkTheme, onDarkThemeChange = { isDarkTheme -> onAction(ProfileAction.OnDarkThemeChange(isDarkTheme)) }, ) VerticalSpacer(12.dp) ThemeColorCard( selectedThemeColor = state.selectedThemeColor, onThemeColorSelected = { theme -> onAction(ProfileAction.OnThemeColorSelected(theme)) }, ) VerticalSpacer(16.dp) if (state.isDarkTheme == true || (state.isDarkTheme == null && isSystemInDarkTheme())) { ToggleSettingCard( title = stringResource(Res.string.amoled_black_theme), description = stringResource(Res.string.amoled_black_description), checked = state.isAmoledThemeEnabled, onCheckedChange = { enabled -> onAction(ProfileAction.OnAmoledThemeToggled(enabled)) }, ) VerticalSpacer(8.dp) } ToggleSettingCard( title = stringResource(Res.string.system_font), description = stringResource(Res.string.system_font_description), checked = state.selectedFontTheme == FontTheme.SYSTEM, onCheckedChange = { enabled -> onAction( ProfileAction.OnFontThemeSelected( if (enabled) { FontTheme.SYSTEM } else { FontTheme.CUSTOM }, ), ) }, ) VerticalSpacer(8.dp) ToggleSettingCard( title = stringResource(Res.string.liquid_glass_option_title), description = stringResource(Res.string.liquid_glass_option_description), checked = state.isLiquidGlassEnabled, onCheckedChange = { enabled -> onAction(ProfileAction.OnLiquidGlassEnabledChange(enabled)) }, ) VerticalSpacer(8.dp) ToggleSettingCard( title = stringResource(Res.string.auto_detect_clipboard_links), description = stringResource(Res.string.auto_detect_clipboard_description), checked = state.autoDetectClipboardLinks, onCheckedChange = { enabled -> onAction(ProfileAction.OnAutoDetectClipboardToggled(enabled)) }, ) VerticalSpacer(8.dp) ToggleSettingCard( title = stringResource(Res.string.hide_seen_title), description = stringResource(Res.string.hide_seen_description), checked = state.isHideSeenEnabled, onCheckedChange = { enabled -> onAction(ProfileAction.OnHideSeenToggled(enabled)) }, ) VerticalSpacer(8.dp) ClearSeenHistoryCard( onClick = { onAction(ProfileAction.OnClearSeenRepos) }, ) } } @Composable private fun VerticalSpacer(height: Dp) { Spacer(Modifier.height(height)) } @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun ThemeSelectionCard( isDarkTheme: Boolean?, onDarkThemeChange: (Boolean?) -> Unit, ) { ExpressiveCard { Row( modifier = Modifier .fillMaxWidth() .padding(16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, ) { ThemeModeOption( icon = Icons.Default.LightMode, label = stringResource(Res.string.theme_light), isSelected = isDarkTheme != null && !isDarkTheme, onClick = { onDarkThemeChange(false) }, modifier = Modifier.weight(1f), ) ThemeModeOption( icon = Icons.Default.DarkMode, label = stringResource(Res.string.theme_dark), isSelected = isDarkTheme == true, onClick = { onDarkThemeChange(true) }, modifier = Modifier.weight(1f), ) ThemeModeOption( icon = Icons.Default.Colorize, label = stringResource(Res.string.theme_system), isSelected = isDarkTheme == null, onClick = { onDarkThemeChange(null) }, modifier = Modifier.weight(1f), ) } } } @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun ThemeModeOption( icon: ImageVector, label: String, isSelected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, ) { val scale by animateFloatAsState( targetValue = if (isSelected) 1.05f else 1f, animationSpec = spring( dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow, ), ) Column( modifier = modifier .scale(scale) .clip(RoundedCornerShape(24.dp)) .background( if (isSelected) { MaterialTheme.colorScheme.primaryContainer } else { MaterialTheme.colorScheme.surface }, ).clickable(onClick = onClick) .padding(vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally, ) { Icon( imageVector = icon, contentDescription = null, modifier = Modifier.size(24.dp), tint = if (isSelected) { MaterialTheme.colorScheme.onPrimaryContainer } else { MaterialTheme.colorScheme.onSurfaceVariant }, ) Text( text = label, style = MaterialTheme.typography.titleMedium, color = if (isSelected) { MaterialTheme.colorScheme.onPrimaryContainer } else { MaterialTheme.colorScheme.onSurface }, ) } } @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun ThemeColorCard( selectedThemeColor: AppTheme, onThemeColorSelected: (AppTheme) -> Unit, ) { ExpressiveCard { Column( modifier = Modifier.padding(16.dp), ) { Text( text = stringResource(Res.string.theme_color), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, ) VerticalSpacer(12.dp) LazyRow( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(20.dp), verticalAlignment = Alignment.CenterVertically, ) { val availableThemes = if (isDynamicColorAvailable()) { AppTheme.entries } else { AppTheme.entries.filter { it != AppTheme.DYNAMIC } } items(availableThemes) { theme -> ThemeColorOption( theme = theme, isSelected = selectedThemeColor == theme, onClick = { onThemeColorSelected(theme) }, ) } } } } } @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun ThemeColorOption( theme: AppTheme, isSelected: Boolean, onClick: () -> Unit, ) { val scale by animateFloatAsState( targetValue = if (isSelected) 1.1f else 1f, animationSpec = spring( dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow, ), ) Column( modifier = Modifier.clickable(onClick = onClick), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp), ) { Box( modifier = Modifier .size(56.dp) .scale(scale) .clip( if (isSelected) { MaterialShapes.Cookie9Sided.toShape() } else { CircleShape }, ).background( color = theme.primaryColor ?: MaterialTheme.colorScheme.primary, ).then( if (theme == AppTheme.DYNAMIC) { Modifier.border( 2.dp, MaterialTheme.colorScheme.outline, if (isSelected) { MaterialShapes.Cookie9Sided.toShape() } else { CircleShape }, ) } else { Modifier }, ), contentAlignment = Alignment.Center, ) { androidx.compose.animation.AnimatedVisibility( visible = isSelected, enter = scaleIn(spring(dampingRatio = Spring.DampingRatioMediumBouncy)) + fadeIn(), exit = scaleOut() + fadeOut(), ) { Icon( imageVector = Icons.Default.Done, contentDescription = stringResource( Res.string.selected_color, theme.displayName, ), modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.onPrimary, ) } } Text( text = theme.displayName, style = MaterialTheme.typography.labelLarge, color = if (isSelected) { MaterialTheme.colorScheme.primary } else { MaterialTheme.colorScheme.onSurfaceVariant }, fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, ) } } @Composable private fun ToggleSettingCard( title: String, description: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit, ) { ExpressiveCard { Row( modifier = Modifier .fillMaxWidth() .toggleable( value = checked, onValueChange = onCheckedChange, role = Role.Switch, interactionSource = remember { MutableInteractionSource() }, indication = ripple(), ).padding(16.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { Column( modifier = Modifier .weight(1f) .padding(end = 16.dp), ) { Text( text = title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, ) VerticalSpacer(4.dp) Text( text = description, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } Switch( checked = checked, onCheckedChange = null, ) } } } @Composable private fun ClearSeenHistoryCard( onClick: () -> Unit, ) { ExpressiveCard { Row( modifier = Modifier .fillMaxWidth() .clickable(onClick = onClick) .padding(16.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { Column( modifier = Modifier.weight(1f), ) { Text( text = stringResource(Res.string.clear_seen_history), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, ) VerticalSpacer(4.dp) Text( text = stringResource(Res.string.clear_seen_history_description), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } } ================================================ FILE: feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Installation.kt ================================================ package zed.rainxch.profile.presentation.components.sections import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring 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.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.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.InstallMobile import androidx.compose.material.icons.outlined.Schedule import androidx.compose.material.icons.outlined.Speed import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import zed.rainxch.core.domain.getPlatform import zed.rainxch.core.domain.model.InstallerType import zed.rainxch.core.domain.model.Platform import zed.rainxch.core.domain.model.ShizukuAvailability import zed.rainxch.core.presentation.components.ExpressiveCard import zed.rainxch.githubstore.core.presentation.res.* import zed.rainxch.profile.presentation.ProfileAction import zed.rainxch.profile.presentation.ProfileState import zed.rainxch.profile.presentation.components.SectionHeader @OptIn(ExperimentalMaterial3ExpressiveApi::class) fun LazyListScope.installationSection( state: ProfileState, onAction: (ProfileAction) -> Unit, ) { if (getPlatform() != Platform.ANDROID) return item { Spacer(Modifier.height(32.dp)) SectionHeader( text = stringResource(Res.string.section_installation).uppercase() ) Spacer(Modifier.height(8.dp)) InstallerTypeCard( selectedType = state.installerType, shizukuAvailability = state.shizukuAvailability, onTypeSelected = { type -> onAction(ProfileAction.OnInstallerTypeSelected(type)) }, onRequestPermission = { onAction(ProfileAction.OnRequestShizukuPermission) } ) // Auto-update toggle — only shown when Shizuku is selected and ready if (state.installerType == InstallerType.SHIZUKU && state.shizukuAvailability == ShizukuAvailability.READY ) { Spacer(Modifier.height(12.dp)) AutoUpdateCard( enabled = state.autoUpdateEnabled, onToggle = { enabled -> onAction(ProfileAction.OnAutoUpdateToggled(enabled)) } ) } } } /** * Updates section — always visible on Android (not gated on Shizuku). * Shows the update check interval picker so all users can configure * how often background update checks run. */ @OptIn(ExperimentalMaterial3ExpressiveApi::class) fun LazyListScope.updatesSection( state: ProfileState, onAction: (ProfileAction) -> Unit, ) { if (getPlatform() != Platform.ANDROID) return item { Spacer(Modifier.height(32.dp)) SectionHeader( text = stringResource(Res.string.section_updates).uppercase() ) Spacer(Modifier.height(8.dp)) UpdateCheckIntervalCard( selectedIntervalHours = state.updateCheckIntervalHours, onIntervalSelected = { hours -> onAction(ProfileAction.OnUpdateCheckIntervalChanged(hours)) } ) Spacer(Modifier.height(12.dp)) PreReleaseToggleCard( enabled = state.includePreReleases, onToggle = { enabled -> onAction(ProfileAction.OnIncludePreReleasesToggled(enabled)) } ) } } @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun InstallerTypeCard( selectedType: InstallerType, shizukuAvailability: ShizukuAvailability, onTypeSelected: (InstallerType) -> Unit, onRequestPermission: () -> Unit ) { ExpressiveCard { Column( modifier = Modifier .padding(16.dp) .selectableGroup(), verticalArrangement = Arrangement.spacedBy(12.dp) ) { InstallerOption( icon = Icons.Outlined.InstallMobile, title = stringResource(Res.string.installer_type_default), description = stringResource(Res.string.installer_type_default_description), isSelected = selectedType == InstallerType.DEFAULT, onClick = { onTypeSelected(InstallerType.DEFAULT) } ) InstallerOption( icon = Icons.Outlined.Speed, title = stringResource(Res.string.installer_type_shizuku), description = stringResource(Res.string.installer_type_shizuku_description), isSelected = selectedType == InstallerType.SHIZUKU, onClick = { onTypeSelected(InstallerType.SHIZUKU) }, statusBadge = { ShizukuStatusBadge( availability = shizukuAvailability ) } ) when (shizukuAvailability) { ShizukuAvailability.PERMISSION_NEEDED -> { FilledTonalButton( onClick = onRequestPermission, modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp) ) { Text( text = stringResource(Res.string.shizuku_grant_permission), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold ) } } ShizukuAvailability.UNAVAILABLE -> { HintText(text = stringResource(Res.string.shizuku_install_hint)) } ShizukuAvailability.NOT_RUNNING -> { HintText(text = stringResource(Res.string.shizuku_start_hint)) } ShizukuAvailability.READY -> { // No hint needed } } } } } @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun InstallerOption( icon: ImageVector, title: String, description: String, isSelected: Boolean, onClick: () -> Unit, statusBadge: (@Composable () -> Unit)? = null ) { val scale by animateFloatAsState( targetValue = if (isSelected) 1.02f else 1f, animationSpec = spring( dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow ) ) Row( modifier = Modifier .fillMaxWidth() .scale(scale) .clip(RoundedCornerShape(16.dp)) .background( if (isSelected) { MaterialTheme.colorScheme.primaryContainer } else { MaterialTheme.colorScheme.surface } ) .selectable( selected = isSelected, onClick = onClick, role = Role.RadioButton ) .padding(12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp) ) { Icon( imageVector = icon, contentDescription = null, modifier = Modifier .size(40.dp) .clip(RoundedCornerShape(12.dp)) .background( if (isSelected) { MaterialTheme.colorScheme.primary.copy(alpha = 0.15f) } else { MaterialTheme.colorScheme.surfaceContainerLow } ) .padding(8.dp), tint = if (isSelected) { MaterialTheme.colorScheme.primary } else { MaterialTheme.colorScheme.onSurfaceVariant } ) Column( modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp) ) { Text( text = title, style = MaterialTheme.typography.titleMedium, color = if (isSelected) { MaterialTheme.colorScheme.onPrimaryContainer } else { MaterialTheme.colorScheme.onSurface }, fontWeight = FontWeight.SemiBold ) Text( text = description, style = MaterialTheme.typography.bodySmall, color = if (isSelected) { MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) } else { MaterialTheme.colorScheme.onSurfaceVariant } ) } if (statusBadge != null) { statusBadge() } } } @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun ShizukuStatusBadge( availability: ShizukuAvailability ) { val (color, label) = when (availability) { ShizukuAvailability.READY -> Pair( Color(0xFF4CAF50), stringResource(Res.string.shizuku_status_ready) ) ShizukuAvailability.PERMISSION_NEEDED -> Pair( Color(0xFFFF9800), stringResource(Res.string.shizuku_status_permission_needed) ) ShizukuAvailability.NOT_RUNNING -> Pair( Color(0xFFFF5722), stringResource(Res.string.shizuku_status_not_running) ) ShizukuAvailability.UNAVAILABLE -> Pair( MaterialTheme.colorScheme.outline, stringResource(Res.string.shizuku_status_not_installed) ) } Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) ) { Box( modifier = Modifier .size(8.dp) .clip(CircleShape) .background(color) ) Text( text = label, style = MaterialTheme.typography.labelSmall, color = color, fontWeight = FontWeight.Medium ) } } @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun AutoUpdateCard( enabled: Boolean, onToggle: (Boolean) -> Unit, ) { ExpressiveCard { Row( modifier = Modifier .fillMaxWidth() .padding(16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp) ) { Column( modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp) ) { Text( text = stringResource(Res.string.auto_update_title), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold ) Text( text = stringResource(Res.string.auto_update_description), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) } Switch( checked = enabled, onCheckedChange = onToggle ) } } } @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun UpdateCheckIntervalCard( selectedIntervalHours: Long, onIntervalSelected: (Long) -> Unit, ) { val intervals = listOf( 3L to Res.string.interval_3h, 6L to Res.string.interval_6h, 12L to Res.string.interval_12h, 24L to Res.string.interval_24h, ) ExpressiveCard { Column( modifier = Modifier .fillMaxWidth() .padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp) ) { Icon( imageVector = Icons.Outlined.Schedule, contentDescription = null, modifier = Modifier .size(40.dp) .clip(RoundedCornerShape(12.dp)) .background(MaterialTheme.colorScheme.primaryContainer) .padding(8.dp), tint = MaterialTheme.colorScheme.onPrimaryContainer ) Column( verticalArrangement = Arrangement.spacedBy(2.dp) ) { Text( text = stringResource(Res.string.update_check_interval_title), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold ) Text( text = stringResource(Res.string.update_check_interval_description), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) } } FlowRow( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { intervals.forEach { (hours, labelRes) -> val isSelected = selectedIntervalHours == hours FilterChip( selected = isSelected, onClick = { onIntervalSelected(hours) }, label = { Text( text = stringResource(labelRes), fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal ) }, shape = RoundedCornerShape(12.dp), colors = FilterChipDefaults.filterChipColors( selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer, ) ) } } } } } @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun PreReleaseToggleCard( enabled: Boolean, onToggle: (Boolean) -> Unit, ) { ExpressiveCard { Row( modifier = Modifier .fillMaxWidth() .padding(16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp) ) { Column( modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp) ) { Text( text = stringResource(Res.string.include_pre_releases_title), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold ) Text( text = stringResource(Res.string.include_pre_releases_description), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) } Switch( checked = enabled, onCheckedChange = onToggle ) } } } @Composable private fun HintText(text: String) { Text( text = text, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 8.dp, top = 4.dp) ) } ================================================ FILE: feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Network.kt ================================================ package zed.rainxch.profile.presentation.components.sections import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically 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.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Save import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.CardDefaults import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import zed.rainxch.githubstore.core.presentation.res.* import zed.rainxch.profile.presentation.ProfileAction import zed.rainxch.profile.presentation.ProfileState import zed.rainxch.profile.presentation.components.SectionHeader import zed.rainxch.profile.presentation.model.ProxyType @OptIn(ExperimentalMaterial3ExpressiveApi::class) fun LazyListScope.networkSection( state: ProfileState, onAction: (ProfileAction) -> Unit, ) { item { SectionHeader( text = stringResource(Res.string.section_network), ) Spacer(Modifier.height(8.dp)) ProxyTypeCard( selectedType = state.proxyType, onTypeSelected = { type -> onAction(ProfileAction.OnProxyTypeSelected(type)) }, ) AnimatedVisibility( visible = state.proxyType == ProxyType.NONE || state.proxyType == ProxyType.SYSTEM, enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut(), ) { Text( text = when (state.proxyType) { ProxyType.SYSTEM -> stringResource(Res.string.proxy_system_description) else -> stringResource(Res.string.proxy_none_description) }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 8.dp, top = 12.dp), ) } AnimatedVisibility( visible = state.proxyType == ProxyType.HTTP || state.proxyType == ProxyType.SOCKS, enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut(), ) { Column { Spacer(Modifier.height(16.dp)) ProxyDetailsCard( state = state, onAction = onAction, ) } } } } @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun ProxyTypeCard( selectedType: ProxyType, onTypeSelected: (ProxyType) -> Unit, ) { ElevatedCard( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.elevatedCardColors( containerColor = MaterialTheme.colorScheme.surfaceContainer, ), shape = RoundedCornerShape(32.dp), ) { Column( modifier = Modifier.padding(16.dp), ) { Text( text = stringResource(Res.string.proxy_type), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, ) Spacer(Modifier.height(8.dp)) LazyRow( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { items(ProxyType.entries) { type -> FilterChip( selected = selectedType == type, onClick = { onTypeSelected(type) }, label = { Text( text = when (type) { ProxyType.NONE -> stringResource(Res.string.proxy_none) ProxyType.SYSTEM -> stringResource(Res.string.proxy_system) ProxyType.HTTP -> stringResource(Res.string.proxy_http) ProxyType.SOCKS -> stringResource(Res.string.proxy_socks) }, fontWeight = if (selectedType == type) FontWeight.Bold else FontWeight.Normal, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis, ) }, ) } } } } } @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun ProxyDetailsCard( state: ProfileState, onAction: (ProfileAction) -> Unit, ) { val portValue = state.proxyPort val isPortInvalid = portValue.isNotEmpty() && (portValue.toIntOrNull()?.let { it !in 1..65535 } ?: true) val isFormValid = state.proxyHost.isNotBlank() && portValue.isNotEmpty() && portValue.toIntOrNull()?.let { it in 1..65535 } == true ElevatedCard( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.elevatedCardColors( containerColor = MaterialTheme.colorScheme.surfaceContainer, ), shape = RoundedCornerShape(32.dp), ) { Column( modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), ) { OutlinedTextField( value = state.proxyHost, onValueChange = { onAction(ProfileAction.OnProxyHostChanged(it)) }, label = { Text(stringResource(Res.string.proxy_host)) }, placeholder = { Text("127.0.0.1") }, singleLine = true, modifier = Modifier.weight(2f), shape = RoundedCornerShape(12.dp), ) OutlinedTextField( value = state.proxyPort, onValueChange = { onAction(ProfileAction.OnProxyPortChanged(it)) }, label = { Text(stringResource(Res.string.proxy_port)) }, placeholder = { Text("1080") }, singleLine = true, isError = isPortInvalid, supportingText = if (isPortInvalid) { { Text(stringResource(Res.string.proxy_port_error)) } } else { null }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.weight(1f), shape = RoundedCornerShape(12.dp), ) } // Username OutlinedTextField( value = state.proxyUsername, onValueChange = { onAction(ProfileAction.OnProxyUsernameChanged(it)) }, label = { Text(stringResource(Res.string.proxy_username)) }, singleLine = true, modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp), ) // Password with visibility toggle OutlinedTextField( value = state.proxyPassword, onValueChange = { onAction(ProfileAction.OnProxyPasswordChanged(it)) }, label = { Text(stringResource(Res.string.proxy_password)) }, singleLine = true, visualTransformation = if (state.isProxyPasswordVisible) { VisualTransformation.None } else { PasswordVisualTransformation() }, trailingIcon = { IconButton( onClick = { onAction(ProfileAction.OnProxyPasswordVisibilityToggle) }, ) { Icon( imageVector = if (state.isProxyPasswordVisible) { Icons.Default.VisibilityOff } else { Icons.Default.Visibility }, contentDescription = if (state.isProxyPasswordVisible) { stringResource(Res.string.proxy_hide_password) } else { stringResource(Res.string.proxy_show_password) }, modifier = Modifier.size(20.dp), ) } }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp), ) // Save button FilledTonalButton( onClick = { onAction(ProfileAction.OnProxySave) }, enabled = isFormValid, modifier = Modifier.align(Alignment.End), ) { Icon( imageVector = Icons.Default.Save, contentDescription = null, modifier = Modifier.size(18.dp), ) Spacer(Modifier.size(8.dp)) Text(stringResource(Res.string.proxy_save)) } } } } ================================================ FILE: feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Options.kt ================================================ package zed.rainxch.profile.presentation.components.sections import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background 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.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.VolunteerActivism import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text 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.Brush import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import zed.rainxch.githubstore.core.presentation.res.* import zed.rainxch.profile.presentation.ProfileAction fun LazyListScope.options( isUserLoggedIn: Boolean, onAction: (ProfileAction) -> Unit, ) { item { OptionCard( icon = Icons.Default.Star, label = stringResource(Res.string.stars), description = stringResource(Res.string.profile_stars_description), onClick = { onAction(ProfileAction.OnStarredReposClick) }, enabled = isUserLoggedIn, ) Spacer(Modifier.height(4.dp)) OptionCard( icon = Icons.Default.Favorite, label = stringResource(Res.string.favourites), description = stringResource(Res.string.profile_favourites_description), onClick = { onAction(ProfileAction.OnFavouriteReposClick) }, ) Spacer(Modifier.height(4.dp)) SponsorCard( onClick = { onAction(ProfileAction.OnSponsorClick) }, ) } } @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun OptionCard( icon: ImageVector, label: String, description: String, onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, ) { Card( modifier = modifier, colors = CardDefaults.elevatedCardColors( containerColor = MaterialTheme.colorScheme.surfaceContainerLow, contentColor = MaterialTheme.colorScheme.onSurface, disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerLow.copy(alpha = .7f), disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = .7f), ), onClick = onClick, shape = RoundedCornerShape(32.dp), border = BorderStroke( width = .5.dp, color = MaterialTheme.colorScheme.surface, ), enabled = enabled, ) { Row( modifier = Modifier.padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { Icon( imageVector = icon, contentDescription = null, modifier = Modifier .size(36.dp) .clip(CircleShape) .background( Brush.linearGradient( listOf( MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.secondary, ), ), ).padding(6.dp), tint = MaterialTheme.colorScheme.onPrimary, ) Column( modifier = Modifier .weight(1f) .padding(12.dp), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.Start, ) { Text( text = label, maxLines = 1, style = MaterialTheme.typography.titleMedium, overflow = TextOverflow.Ellipsis, color = MaterialTheme.colorScheme.onSurface, ) Text( text = description, maxLines = 2, style = MaterialTheme.typography.bodyLargeEmphasized, overflow = TextOverflow.Ellipsis, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } } @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun SponsorCard( onClick: () -> Unit, modifier: Modifier = Modifier, ) { Card( modifier = modifier, onClick = onClick, colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.primaryContainer, contentColor = MaterialTheme.colorScheme.onPrimaryContainer, ), shape = RoundedCornerShape(32.dp), border = BorderStroke( width = 1.dp, color = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f), ), ) { Row( modifier = Modifier.padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { Icon( imageVector = Icons.Default.VolunteerActivism, contentDescription = null, modifier = Modifier .size(36.dp) .clip(CircleShape) .background( Brush.linearGradient( listOf( MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.tertiary, ), ), ).padding(6.dp), tint = MaterialTheme.colorScheme.onPrimary, ) Column( modifier = Modifier .weight(1f) .padding(12.dp), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.Start, ) { Text( text = stringResource(Res.string.sponsor_button), maxLines = 1, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, overflow = TextOverflow.Ellipsis, color = MaterialTheme.colorScheme.onPrimaryContainer, ) Text( text = stringResource(Res.string.sponsor_hero_subtitle), maxLines = 2, style = MaterialTheme.typography.bodySmall, overflow = TextOverflow.Ellipsis, color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f), ) } } } } ================================================ FILE: feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Others.kt ================================================ package zed.rainxch.profile.presentation.components.sections import androidx.compose.foundation.background 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.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Storage import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text 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.unit.dp import org.jetbrains.compose.resources.stringResource import zed.rainxch.core.presentation.components.ExpressiveCard import zed.rainxch.githubstore.core.presentation.res.* import zed.rainxch.profile.presentation.ProfileAction import zed.rainxch.profile.presentation.ProfileState import zed.rainxch.profile.presentation.components.SectionHeader @OptIn(ExperimentalMaterial3ExpressiveApi::class) fun LazyListScope.othersSection( state: ProfileState, onAction: (ProfileAction) -> Unit, ) { item { SectionHeader( text = stringResource(Res.string.storage).uppercase(), ) Spacer(Modifier.height(8.dp)) ExpressiveCard { Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { Icon( imageVector = Icons.Outlined.Storage, contentDescription = null, modifier = Modifier .size(44.dp) .clip(RoundedCornerShape(36.dp)) .background(MaterialTheme.colorScheme.surfaceContainerLow) .padding(8.dp), ) Column( modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp), horizontalAlignment = Alignment.Start, ) { Text( text = stringResource(Res.string.clear_cache), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, ) Text( text = "${stringResource(Res.string.current_size)} ${state.cacheSize}", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } Button( onClick = { onAction(ProfileAction.OnClearCacheClick) }, shape = RoundedCornerShape(12.dp), colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, contentColor = MaterialTheme.colorScheme.onSurface, ), ) { Text( text = stringResource(Res.string.clear), style = MaterialTheme.typography.titleMediumEmphasized, fontWeight = FontWeight.Bold, ) } } } } } ================================================ FILE: feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/ProfileSection.kt ================================================ package zed.rainxch.profile.presentation.components.sections import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import zed.rainxch.profile.presentation.ProfileAction import zed.rainxch.profile.presentation.ProfileState fun LazyListScope.profile( state: ProfileState, onAction: (ProfileAction) -> Unit, ) { accountSection( state = state, onAction = onAction, ) item { Spacer(Modifier.height(20.dp)) } options( isUserLoggedIn = state.isUserLoggedIn, onAction = onAction, ) } ================================================ FILE: feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/SettingsSection.kt ================================================ package zed.rainxch.profile.presentation.components.sections import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import zed.rainxch.profile.presentation.ProfileAction import zed.rainxch.profile.presentation.ProfileState fun LazyListScope.settings( state: ProfileState, onAction: (ProfileAction) -> Unit, ) { appearanceSection( state = state, onAction = onAction, ) item { Spacer(Modifier.height(32.dp)) } networkSection( state = state, onAction = onAction, ) item { Spacer(Modifier.height(12.dp)) } installationSection( state = state, onAction = onAction, ) updatesSection( state = state, onAction = onAction, ) } ================================================ FILE: feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/model/ProxyType.kt ================================================ package zed.rainxch.profile.presentation.model import zed.rainxch.core.domain.model.ProxyConfig enum class ProxyType { NONE, SYSTEM, HTTP, SOCKS, ; companion object { fun fromConfig(config: ProxyConfig): ProxyType = when (config) { is ProxyConfig.None -> NONE is ProxyConfig.System -> SYSTEM is ProxyConfig.Http -> HTTP is ProxyConfig.Socks -> SOCKS } } } ================================================ FILE: feature/search/CLAUDE.md ================================================ # CLAUDE.md - Search Feature ## Purpose Repository search with advanced filters. Users can search GitHub repositories by query and filter by platform (Android, Windows, macOS, Linux), programming language, and sort order. Supports paginated results. ## Module Structure ``` feature/search/ ├── domain/ │ ├── model/ │ │ ├── SearchPlatform.kt # All, Android, Windows, macOS, Linux │ │ ├── ProgrammingLanguage.kt # Language filter options │ │ └── SortBy.kt # Sort options (stars, updated, etc.) │ └── repository/SearchRepository.kt # Filtered, paginated search ├── data/ │ ├── di/SharedModule.kt # Koin: searchModule │ ├── repository/SearchRepositoryImpl.kt # GitHub search API integration │ ├── dto/ # Network DTOs │ └── mappers/ # DTO → domain model mappers └── presentation/ ├── SearchViewModel.kt # Search state, filter management, pagination ├── SearchState.kt # query, results, filters, loading state ├── SearchAction.kt # Search, filter changes, load more, clicks ├── SearchEvent.kt # One-off events ├── SearchRoot.kt # Main composable with search bar + filter dropdowns └── components/ # Filter UI components ``` ## Key Interfaces ```kotlin interface SearchRepository { fun searchRepositories( query: String, searchPlatform: SearchPlatform, language: ProgrammingLanguage, sortBy: SortBy, page: Int ): Flow } ``` ## Navigation Route: `GithubStoreGraph.SearchScreen` (data object, no params) ## Implementation Notes - Platform filter maps to GitHub topic searches (e.g., `android` topic for Android platform) - Language filter maps to GitHub's `language:` qualifier - Search results use the same `PaginatedDiscoveryRepositories` model as home feature - Debounce/throttle applied to search queries to avoid excessive API calls - Integrates with favourites and starred status from core repositories ================================================ FILE: feature/search/data/.gitignore ================================================ /build ================================================ FILE: feature/search/data/build.gradle.kts ================================================ plugins { alias(libs.plugins.convention.kmp.library) alias(libs.plugins.convention.buildkonfig) } kotlin { sourceSets { commonMain { dependencies { implementation(libs.kotlin.stdlib) implementation(projects.core.domain) implementation(projects.core.data) implementation(projects.feature.search.domain) implementation(libs.kotlinx.coroutines.core) implementation(libs.bundles.ktor.common) implementation(libs.bundles.koin.common) } } androidMain { dependencies { } } jvmMain { dependencies { } } } } ================================================ FILE: feature/search/data/src/androidMain/AndroidManifest.xml ================================================ ================================================ FILE: feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/di/SharedModule.kt ================================================ package zed.rainxch.search.data.di import org.koin.dsl.module import zed.rainxch.domain.repository.SearchRepository import zed.rainxch.search.data.repository.SearchRepositoryImpl val searchModule = module { single { SearchRepositoryImpl( httpClient = get(), cacheManager = get(), ) } } ================================================ FILE: feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/dto/AssetNetworkModel.kt ================================================ package zed.rainxch.search.data.dto import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class AssetNetworkModel( @SerialName("name") val name: String, ) ================================================ FILE: feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/dto/GithubReleaseNetworkModel.kt ================================================ package zed.rainxch.search.data.dto import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class GithubReleaseNetworkModel( @SerialName("draft") val draft: Boolean? = null, @SerialName("prerelease") val prerelease: Boolean? = null, @SerialName("assets") val assets: List, @SerialName("published_at") val publishedAt: String? = null, ) ================================================ FILE: feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/repository/SearchRepositoryImpl.kt ================================================ package zed.rainxch.search.data.repository import io.ktor.client.HttpClient import io.ktor.client.request.get import io.ktor.client.request.header import io.ktor.client.request.parameter import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withTimeoutOrNull import zed.rainxch.core.data.cache.CacheManager import zed.rainxch.core.data.cache.CacheManager.CacheTtl.SEARCH_RESULTS import zed.rainxch.core.data.dto.GithubRepoNetworkModel import zed.rainxch.core.data.dto.GithubRepoSearchResponse import zed.rainxch.core.data.mappers.toSummary import zed.rainxch.core.data.network.executeRequest import zed.rainxch.core.domain.model.DiscoveryPlatform import zed.rainxch.core.domain.model.GithubRepoSummary import zed.rainxch.core.domain.model.PaginatedDiscoveryRepositories import zed.rainxch.core.domain.model.RateLimitException import zed.rainxch.domain.model.ProgrammingLanguage import zed.rainxch.domain.model.SortBy import zed.rainxch.domain.model.SortOrder import zed.rainxch.domain.repository.SearchRepository import zed.rainxch.search.data.dto.GithubReleaseNetworkModel import zed.rainxch.search.data.utils.LruCache class SearchRepositoryImpl( private val httpClient: HttpClient, private val cacheManager: CacheManager, ) : SearchRepository { private val releaseCheckCache = LruCache(maxSize = 500) private val cacheMutex = Mutex() companion object { private const val PER_PAGE = 100 private const val VERIFY_CONCURRENCY = 15 private const val PER_CHECK_TIMEOUT_MS = 2000L private const val MAX_AUTO_SKIP_PAGES = 3 } private fun searchCacheKey( query: String, platform: DiscoveryPlatform, language: ProgrammingLanguage, sortBy: SortBy, sortOrder: SortOrder, page: Int, ): String { val queryHash = query .trim() .lowercase() .hashCode() .toUInt() .toString(16) return "search:$queryHash:${platform.name}:${language.name}:${sortBy.name}:${sortOrder.name}:page$page" } override fun searchRepositories( query: String, platform: DiscoveryPlatform, language: ProgrammingLanguage, sortBy: SortBy, sortOrder: SortOrder, page: Int, ): Flow = channelFlow { val cacheKey = searchCacheKey(query, platform, language, sortBy, sortOrder, page) val cached = cacheManager.get(cacheKey) if (cached != null) { send(cached) return@channelFlow } val searchQuery = buildSearchQuery(query, language) val sort = sortBy.toGithubSortParam() val order = sortOrder.toGithubParam() try { var currentPage = page var pagesSkipped = 0 while (pagesSkipped <= MAX_AUTO_SKIP_PAGES) { currentCoroutineContext().ensureActive() val response = httpClient .executeRequest { get("/search/repositories") { parameter("q", searchQuery) parameter("per_page", PER_PAGE) parameter("page", currentPage) if (sort != null) { parameter("sort", sort) parameter("order", order) } } }.getOrThrow() val total = response.totalCount val baseHasMore = (currentPage * PER_PAGE) < total && response.items.isNotEmpty() if (response.items.isEmpty()) { send( PaginatedDiscoveryRepositories( repos = emptyList(), hasMore = false, nextPageIndex = currentPage + 1, totalCount = total, ), ) return@channelFlow } val verified = verifyBatch(response.items, platform) if (verified.isNotEmpty()) { val result = PaginatedDiscoveryRepositories( repos = verified, hasMore = baseHasMore, nextPageIndex = currentPage + 1, totalCount = total, ) cacheManager.put(cacheKey, result, SEARCH_RESULTS) send(result) return@channelFlow } if (!baseHasMore) { send( PaginatedDiscoveryRepositories( repos = emptyList(), hasMore = false, nextPageIndex = currentPage + 1, totalCount = total, ), ) return@channelFlow } currentPage++ pagesSkipped++ } send( PaginatedDiscoveryRepositories( repos = emptyList(), hasMore = true, nextPageIndex = currentPage + 1, totalCount = null, ), ) } catch (e: RateLimitException) { throw e } catch (e: CancellationException) { throw e } }.flowOn(Dispatchers.IO) private suspend fun verifyBatch( items: List, searchPlatform: DiscoveryPlatform, ): List { val semaphore = Semaphore(VERIFY_CONCURRENCY) val deferredChecks = coroutineScope { items.map { repo -> async { try { semaphore.withPermit { withTimeoutOrNull(PER_CHECK_TIMEOUT_MS) { checkRepoHasInstallersCached(repo, searchPlatform) } } } catch (_: CancellationException) { null } } } } return buildList { for (i in items.indices) { currentCoroutineContext().ensureActive() val result = try { deferredChecks[i].await() } catch (e: CancellationException) { throw e } catch (_: Exception) { null } if (result != null) add(result) } } } private fun buildSearchQuery( userQuery: String, language: ProgrammingLanguage, ): String { val clean = userQuery.trim() val q = if (clean.isBlank()) { "stars:>100" } else { "\"$clean\"" } val scope = " in:name,description" val common = " archived:false fork:true" val languageFilter = if (language != ProgrammingLanguage.All && language.queryValue != null) { " language:${language.queryValue}" } else { "" } return ("$q$scope$common" + languageFilter).trim() } private fun assetMatchesPlatform( nameRaw: String, platform: DiscoveryPlatform, ): Boolean { val name = nameRaw.lowercase() return when (platform) { DiscoveryPlatform.All -> { name.endsWith(".apk") || name.endsWith(".msi") || name.endsWith(".exe") || name.endsWith(".dmg") || name.endsWith(".pkg") || name.endsWith(".appimage") || name.endsWith(".deb") || name.endsWith(".rpm") } DiscoveryPlatform.Android -> { name.endsWith(".apk") } DiscoveryPlatform.Windows -> { name.endsWith(".exe") || name.endsWith(".msi") } DiscoveryPlatform.Macos -> { name.endsWith(".dmg") || name.endsWith(".pkg") } DiscoveryPlatform.Linux -> { name.endsWith(".appimage") || name.endsWith(".deb") || name.endsWith(".rpm") } } } private fun detectAvailablePlatforms(assetNames: List): List = buildList { DiscoveryPlatform.entries .filter { it != DiscoveryPlatform.All } .forEach { platform -> if (assetNames.any { assetMatchesPlatform(it, platform) }) { add(platform) } } } private suspend fun checkRepoHasInstallers( repo: GithubRepoNetworkModel, targetPlatform: DiscoveryPlatform, ): GithubRepoSummary? { return try { val allReleases = httpClient .executeRequest> { get("/repos/${repo.owner.login}/${repo.name}/releases") { header("Accept", "application/vnd.github.v3+json") parameter("per_page", 5) } }.getOrNull() ?: return null val stableRelease = allReleases.firstOrNull { it.draft != true && it.prerelease != true } if (stableRelease == null || stableRelease.assets.isEmpty()) { return null } val hasRelevantAssets = stableRelease.assets.any { asset -> assetMatchesPlatform(asset.name, targetPlatform) } if (hasRelevantAssets) { val assetNames = stableRelease.assets.map { it.name } val platforms = detectAvailablePlatforms(assetNames) val summary = repo.toSummary() summary.copy( updatedAt = stableRelease.publishedAt ?: summary.updatedAt, availablePlatforms = platforms, ) } else { null } } catch (_: Exception) { null } } private suspend fun checkRepoHasInstallersCached( repo: GithubRepoNetworkModel, targetPlatform: DiscoveryPlatform, ): GithubRepoSummary? { val key = "${repo.owner.login}/${repo.name}:LATEST_PLATFORM_${targetPlatform.name}" val cached = cacheMutex.withLock { if (releaseCheckCache.contains(key)) releaseCheckCache.get(key) else null } if (cached != null || cacheMutex.withLock { releaseCheckCache.contains(key) && releaseCheckCache.get(key) == null } ) { return cached } val result = checkRepoHasInstallers(repo, targetPlatform) cacheMutex.withLock { releaseCheckCache.put(key, result) } return result } } ================================================ FILE: feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/utils/LruCache.kt ================================================ package zed.rainxch.search.data.utils class LruCache( private val maxSize: Int, ) { private val map = LinkedHashMap() private val order = ArrayDeque() fun get(key: K): V? { val value = map[key] if (value != null || map.containsKey(key)) { order.remove(key) order.addLast(key) } return value } fun put( key: K, value: V?, ) { map[key] = value order.remove(key) order.addLast(key) while (order.size > maxSize) { val oldest = order.removeFirst() map.remove(oldest) } } fun contains(key: K): Boolean = map.containsKey(key) } ================================================ FILE: feature/search/domain/.gitignore ================================================ /build ================================================ FILE: feature/search/domain/build.gradle.kts ================================================ plugins { alias(libs.plugins.convention.kmp.library) } kotlin { sourceSets { commonMain { dependencies { implementation(libs.kotlin.stdlib) implementation(projects.core.domain) implementation(libs.kotlinx.coroutines.core) } } androidMain { dependencies { } } jvmMain { dependencies { } } } } ================================================ FILE: feature/search/domain/src/androidMain/AndroidManifest.xml ================================================ ================================================ FILE: feature/search/domain/src/commonMain/kotlin/zed/rainxch/domain/model/ProgrammingLanguage.kt ================================================ package zed.rainxch.domain.model enum class ProgrammingLanguage( val queryValue: String?, ) { All(null), Kotlin("kotlin"), Java("java"), JavaScript("javascript"), TypeScript("typescript"), Python("python"), Swift("swift"), Rust("rust"), Go("go"), CSharp("c#"), CPlusPlus("c++"), C("c"), Dart("dart"), Ruby("ruby"), PHP("php"), ; companion object { fun fromLanguageString(lang: String?): ProgrammingLanguage { if (lang == null) return All return entries.find { it.queryValue?.equals(lang, ignoreCase = true) == true } ?: All } } } ================================================ FILE: feature/search/domain/src/commonMain/kotlin/zed/rainxch/domain/model/SortBy.kt ================================================ package zed.rainxch.domain.model enum class SortBy { MostStars, MostForks, BestMatch, ; fun toGithubSortParam(): String? = when (this) { MostStars -> "stars" MostForks -> "forks" BestMatch -> null } } ================================================ FILE: feature/search/domain/src/commonMain/kotlin/zed/rainxch/domain/model/SortOrder.kt ================================================ package zed.rainxch.domain.model enum class SortOrder { Descending, Ascending, ; fun toGithubParam(): String = when (this) { Descending -> "desc" Ascending -> "asc" } } ================================================ FILE: feature/search/domain/src/commonMain/kotlin/zed/rainxch/domain/repository/SearchRepository.kt ================================================ package zed.rainxch.domain.repository import kotlinx.coroutines.flow.Flow import zed.rainxch.core.domain.model.DiscoveryPlatform import zed.rainxch.core.domain.model.PaginatedDiscoveryRepositories import zed.rainxch.domain.model.ProgrammingLanguage import zed.rainxch.domain.model.SortBy import zed.rainxch.domain.model.SortOrder interface SearchRepository { fun searchRepositories( query: String, platform: DiscoveryPlatform, language: ProgrammingLanguage, sortBy: SortBy, sortOrder: SortOrder, page: Int, ): Flow } ================================================ FILE: feature/search/presentation/.gitignore ================================================ /build ================================================ FILE: feature/search/presentation/build.gradle.kts ================================================ plugins { alias(libs.plugins.convention.cmp.feature) } kotlin { sourceSets { commonMain { dependencies { implementation(libs.kotlin.stdlib) implementation(projects.core.domain) implementation(projects.core.presentation) implementation(projects.feature.search.domain) implementation(libs.liquid) implementation(libs.androidx.compose.ui.tooling.preview) implementation(compose.components.resources) implementation(libs.kotlinx.collections.immutable) } } } } ================================================ FILE: feature/search/presentation/src/androidMain/AndroidManifest.xml ================================================ ================================================ FILE: feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchAction.kt ================================================ package zed.rainxch.search.presentation import zed.rainxch.core.presentation.model.GithubRepoSummaryUi import zed.rainxch.search.presentation.model.ProgrammingLanguageUi import zed.rainxch.search.presentation.model.SearchPlatformUi import zed.rainxch.search.presentation.model.SortByUi import zed.rainxch.search.presentation.model.SortOrderUi sealed interface SearchAction { data class OnSearchChange( val query: String, ) : SearchAction data class OnPlatformTypeSelected( val searchPlatform: SearchPlatformUi, ) : SearchAction data class OnLanguageSelected( val language: ProgrammingLanguageUi, ) : SearchAction data class OnSortBySelected( val sortBy: SortByUi, ) : SearchAction data class OnSortOrderSelected( val sortOrder: SortOrderUi, ) : SearchAction data class OnRepositoryClick( val repository: GithubRepoSummaryUi, ) : SearchAction data class OnRepositoryDeveloperClick( val username: String, ) : SearchAction data class OnShareClick( val repo: GithubRepoSummaryUi, ) : SearchAction data class OpenGithubLink( val owner: String, val repo: String, ) : SearchAction data object OnSearchImeClick : SearchAction data object OnNavigateBackClick : SearchAction data object LoadMore : SearchAction data object OnClearClick : SearchAction data object Retry : SearchAction data object OnToggleLanguageSheetVisibility : SearchAction data object OnToggleSortByDialogVisibility : SearchAction data object OnFabClick : SearchAction data object DismissClipboardBanner : SearchAction } ================================================ FILE: feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchEvent.kt ================================================ package zed.rainxch.search.presentation sealed interface SearchEvent { data class OnMessage( val message: String, ) : SearchEvent data class NavigateToRepo( val owner: String, val repo: String, ) : SearchEvent } ================================================ FILE: feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchRoot.kt ================================================ package zed.rainxch.search.presentation import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll 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.Row 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.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells import androidx.compose.foundation.lazy.staggeredgrid.items import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.automirrored.filled.Sort import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.outlined.KeyboardArrowDown import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilterChip import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState 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.Color 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.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.github.fletchmckee.liquid.liquefiable import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import zed.rainxch.core.presentation.components.GithubStoreButton import zed.rainxch.core.presentation.components.RepositoryCard import zed.rainxch.core.presentation.locals.LocalBottomNavigationHeight import zed.rainxch.core.presentation.locals.LocalBottomNavigationLiquid import zed.rainxch.core.presentation.theme.GithubStoreTheme import zed.rainxch.core.presentation.utils.ObserveAsEvents import zed.rainxch.githubstore.core.presentation.res.* import zed.rainxch.search.presentation.components.LanguageFilterBottomSheet import zed.rainxch.search.presentation.components.SortByBottomSheet import zed.rainxch.search.presentation.model.ParsedGithubLink import zed.rainxch.search.presentation.model.ProgrammingLanguageUi import zed.rainxch.search.presentation.model.SearchPlatformUi import zed.rainxch.search.presentation.model.SortByUi import zed.rainxch.search.presentation.utils.label @OptIn(ExperimentalMaterial3Api::class) @Composable fun SearchRoot( onNavigateBack: () -> Unit, onNavigateToDetails: (repoId: Long) -> Unit, onNavigateToDetailsFromLink: (owner: String, repo: String) -> Unit, onNavigateToDeveloperProfile: (username: String) -> Unit, viewModel: SearchViewModel = koinViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() val snackbarHost = remember { SnackbarHostState() } ObserveAsEvents(viewModel.events) { event -> when (event) { is SearchEvent.OnMessage -> { scope.launch { snackbarHost.showSnackbar(event.message) } } is SearchEvent.NavigateToRepo -> { onNavigateToDetailsFromLink(event.owner, event.repo) } } } SearchScreen( state = state, snackbarHost = snackbarHost, onAction = { action -> when (action) { is SearchAction.OnRepositoryClick -> { onNavigateToDetails(action.repository.id) } SearchAction.OnNavigateBackClick -> { onNavigateBack() } is SearchAction.OnRepositoryDeveloperClick -> { onNavigateToDeveloperProfile(action.username) } else -> { viewModel.onAction(action) } } }, ) if (state.isLanguageSheetVisible) { LanguageFilterBottomSheet( selectedLanguage = state.selectedLanguage, onLanguageSelected = { language -> viewModel.onAction(SearchAction.OnLanguageSelected(language)) }, onDismissRequest = { viewModel.onAction(SearchAction.OnToggleLanguageSheetVisibility) }, ) } if (state.isSortByDialogVisible) { SortByBottomSheet( selectedSortBy = state.selectedSortBy, selectedSortOrder = state.selectedSortOrder, onSortBySelected = { sortBy -> viewModel.onAction(SearchAction.OnSortBySelected(sortBy)) }, onSortOrderSelected = { sortOrder -> viewModel.onAction(SearchAction.OnSortOrderSelected(sortOrder)) }, onDismissRequest = { viewModel.onAction(SearchAction.OnToggleSortByDialogVisibility) }, ) } } @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun SearchScreen( state: SearchState, snackbarHost: SnackbarHostState, onAction: (SearchAction) -> Unit, ) { val focusRequester = remember { FocusRequester() } val listState = rememberLazyStaggeredGridState() val liquidState = LocalBottomNavigationLiquid.current val bottomNavHeight = LocalBottomNavigationHeight.current val shouldLoadMore by remember { derivedStateOf { val layoutInfo = listState.layoutInfo val totalItems = layoutInfo.totalItemsCount val visibleItems = layoutInfo.visibleItemsInfo if (totalItems == 0 || state.isLoadingMore || state.isLoading || !state.hasMorePages ) { return@derivedStateOf false } val lastVisibleItem = visibleItems.lastOrNull() ?: return@derivedStateOf false val viewportEndOffset = layoutInfo.viewportEndOffset val hasEmptySpaceAtBottom = lastVisibleItem.index == totalItems - 1 && lastVisibleItem.offset.y + lastVisibleItem.size.height < viewportEndOffset val threshold = (totalItems * 0.8f).toInt() val isNearEnd = lastVisibleItem.index >= threshold isNearEnd || hasEmptySpaceAtBottom } } val currentOnAction by rememberUpdatedState(onAction) LaunchedEffect(shouldLoadMore) { if (shouldLoadMore) { currentOnAction(SearchAction.LoadMore) } } LaunchedEffect(listState.layoutInfo.totalItemsCount, listState.layoutInfo.viewportEndOffset) { val layoutInfo = listState.layoutInfo val visibleItems = layoutInfo.visibleItemsInfo val lastVisible = visibleItems.lastOrNull() if (lastVisible != null && layoutInfo.totalItemsCount > 0 && !state.isLoadingMore && !state.isLoading && state.hasMorePages ) { val hasEmptySpace = lastVisible.index == layoutInfo.totalItemsCount - 1 && lastVisible.offset.y + lastVisible.size.height < layoutInfo.viewportEndOffset if (hasEmptySpace) { delay(100) currentOnAction(SearchAction.LoadMore) } } } LaunchedEffect(Unit) { if (state.query.isEmpty()) { focusRequester.requestFocus() } } Scaffold( topBar = { SearchTopbar( onAction = onAction, state = state, focusRequester = focusRequester, ) }, snackbarHost = { SnackbarHost( hostState = snackbarHost, modifier = Modifier.padding(bottom = bottomNavHeight + 16.dp), ) }, floatingActionButton = { FloatingActionButton( onClick = { onAction(SearchAction.OnFabClick) }, modifier = Modifier.padding(bottom = bottomNavHeight + 16.dp), ) { Row( modifier = Modifier.padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Icon( imageVector = Icons.Default.Link, contentDescription = null, modifier = Modifier.size(24.dp), ) Text( text = stringResource(Res.string.open_github_link), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, ) } } }, containerColor = MaterialTheme.colorScheme.background, modifier = Modifier.then( if (state.isLiquidGlassEnabled) { Modifier.liquefiable(liquidState) } else { Modifier }, ), ) { innerPadding -> Column( modifier = Modifier .fillMaxSize() .padding(innerPadding) .padding(horizontal = 16.dp), ) { // Clipboard banner AnimatedVisibility( visible = state.isClipboardBannerVisible && state.clipboardLinks.isNotEmpty(), enter = slideInVertically() + fadeIn(), exit = slideOutVertically() + fadeOut(), ) { ClipboardBanner( links = state.clipboardLinks, onOpenLink = { link -> onAction(SearchAction.OpenGithubLink(link.owner, link.repo)) }, onDismiss = { onAction(SearchAction.DismissClipboardBanner) }, ) } // Detected links from search query AnimatedVisibility( visible = state.detectedLinks.isNotEmpty(), enter = slideInVertically() + fadeIn(), exit = slideOutVertically() + fadeOut(), ) { DetectedLinksSection( links = state.detectedLinks, onOpenLink = { link -> onAction(SearchAction.OpenGithubLink(link.owner, link.repo)) }, ) } LazyRow( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { items(SearchPlatformUi.entries) { sortBy -> FilterChip( selected = state.selectedSearchPlatform == sortBy, label = { Text( text = sortBy.name.lowercase().replaceFirstChar { it.uppercase() }, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onBackground, ) }, onClick = { onAction(SearchAction.OnPlatformTypeSelected(sortBy)) }, ) } } Row( modifier = Modifier .fillMaxWidth() .horizontalScroll(rememberScrollState()), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { Row( horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( text = stringResource(Res.string.language_label), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, fontWeight = FontWeight.Medium, ) FilterChip( selected = state.selectedLanguage != ProgrammingLanguageUi.All, onClick = { onAction(SearchAction.OnToggleLanguageSheetVisibility) }, label = { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { Text( text = stringResource(state.selectedLanguage.label()), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, ) Icon( imageVector = Icons.Outlined.KeyboardArrowDown, contentDescription = null, modifier = Modifier.size(18.dp), ) } }, ) if (state.selectedLanguage != ProgrammingLanguageUi.All) { IconButton( onClick = { onAction(SearchAction.OnLanguageSelected(ProgrammingLanguageUi.All)) }, modifier = Modifier.size(32.dp), ) { Icon( imageVector = Icons.Default.Close, contentDescription = null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } Row( horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( text = stringResource(Res.string.sort_label), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, fontWeight = FontWeight.Medium, ) FilterChip( selected = state.selectedSortBy != SortByUi.BestMatch, onClick = { onAction(SearchAction.OnToggleSortByDialogVisibility) }, label = { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { Icon( imageVector = Icons.AutoMirrored.Filled.Sort, contentDescription = null, modifier = Modifier.size(18.dp), ) Text( text = stringResource(state.selectedSortBy.label()), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, ) Icon( imageVector = Icons.Outlined.KeyboardArrowDown, contentDescription = null, modifier = Modifier.size(18.dp), ) } }, ) } } Spacer(Modifier.height(6.dp)) if (state.totalCount != null) { Text( text = stringResource( Res.string.results_found, state.totalCount, ), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.outline, modifier = Modifier .fillMaxWidth() .padding(bottom = 6.dp), ) } val visibleRepos by remember(state.repositories, state.isHideSeenEnabled, state.seenRepoIds) { derivedStateOf { if (state.isHideSeenEnabled && state.seenRepoIds.isNotEmpty()) { state.repositories.filter { it.repository.id !in state.seenRepoIds } } else { state.repositories } } } Box(Modifier.fillMaxSize()) { if (state.isLoading && state.repositories.isEmpty()) { Box( modifier = Modifier.fillMaxSize().imePadding(), contentAlignment = Alignment.Center, ) { CircularWavyProgressIndicator() } } if (state.errorMessage != null && state.repositories.isEmpty()) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( text = state.errorMessage, ) Spacer(Modifier.height(8.dp)) GithubStoreButton( text = stringResource(Res.string.retry), onClick = { onAction(SearchAction.Retry) }, ) } } } if (visibleRepos.isNotEmpty()) { LazyVerticalStaggeredGrid( state = listState, columns = StaggeredGridCells.Adaptive(350.dp), verticalItemSpacing = 12.dp, horizontalArrangement = Arrangement.spacedBy(12.dp), contentPadding = PaddingValues(horizontal = 8.dp, vertical = 12.dp), modifier = Modifier .fillMaxSize() .then( if (state.isLiquidGlassEnabled) { Modifier.liquefiable(liquidState) } else { Modifier }, ), ) { items( items = visibleRepos, key = { it.repository.id }, ) { discoveryRepository -> RepositoryCard( discoveryRepositoryUi = discoveryRepository, onClick = { onAction(SearchAction.OnRepositoryClick(discoveryRepository.repository)) }, onDeveloperClick = { username -> onAction(SearchAction.OnRepositoryDeveloperClick(username)) }, onShareClick = { onAction(SearchAction.OnShareClick(discoveryRepository.repository)) }, modifier = Modifier .animateItem() .then( if (state.isLiquidGlassEnabled) { Modifier.liquefiable(liquidState) } else { Modifier }, ), ) } item { if (state.isLoadingMore) { Box( modifier = Modifier .fillMaxWidth() .padding(16.dp), contentAlignment = Alignment.Center, ) { CircularProgressIndicator( modifier = Modifier.size(24.dp), ) } } } } } } } } } @Composable private fun ClipboardBanner( links: ImmutableList, onOpenLink: (ParsedGithubLink) -> Unit, onDismiss: () -> Unit, ) { Card( modifier = Modifier .fillMaxWidth() .padding(bottom = 8.dp), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.secondaryContainer, ), shape = RoundedCornerShape(12.dp), ) { Column( modifier = Modifier .fillMaxWidth() .padding(12.dp), ) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { Text( text = stringResource(Res.string.clipboard_link_detected), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSecondaryContainer, fontWeight = FontWeight.Medium, ) IconButton( onClick = onDismiss, modifier = Modifier.size(24.dp), ) { Icon( imageVector = Icons.Default.Close, contentDescription = stringResource(Res.string.dismiss), modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSecondaryContainer, ) } } Spacer(Modifier.height(4.dp)) links.forEach { link -> Row( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(8.dp)) .clickable { onOpenLink(link) } .padding(vertical = 6.dp, horizontal = 4.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Icon( imageVector = Icons.Default.Link, contentDescription = null, modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSecondaryContainer, ) Text( text = "${link.owner}/${link.repo}", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSecondaryContainer, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f), ) Icon( imageVector = Icons.AutoMirrored.Filled.OpenInNew, contentDescription = stringResource(Res.string.open_in_app), modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.primary, ) } } } } } @Composable private fun DetectedLinksSection( links: ImmutableList, onOpenLink: (ParsedGithubLink) -> Unit, ) { Column( modifier = Modifier .fillMaxWidth() .padding(bottom = 8.dp), ) { Text( text = stringResource(Res.string.detected_links), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, fontWeight = FontWeight.Medium, modifier = Modifier.padding(bottom = 4.dp), ) links.forEach { link -> Card( modifier = Modifier .fillMaxWidth() .padding(vertical = 2.dp), onClick = { onOpenLink(link) }, colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.primaryContainer, ), shape = RoundedCornerShape(8.dp), ) { Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 12.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Icon( imageVector = Icons.Default.Link, contentDescription = null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onPrimaryContainer, ) Text( text = "${link.owner}/${link.repo}", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onPrimaryContainer, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f), ) Text( text = stringResource(Res.string.open_in_app), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold, ) } } } } } @Composable private fun SearchTopbar( onAction: (SearchAction) -> Unit, state: SearchState, focusRequester: FocusRequester, ) { Row( modifier = Modifier .fillMaxWidth() .statusBarsPadding() .padding(horizontal = 8.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { TextField( value = state.query, onValueChange = { value -> onAction(SearchAction.OnSearchChange(value)) }, leadingIcon = { Icon( imageVector = Icons.Default.Search, contentDescription = null, modifier = Modifier.size(20.dp), ) }, trailingIcon = { IconButton( onClick = { onAction(SearchAction.OnClearClick) }, modifier = Modifier .size(24.dp) .clip(CircleShape), ) { Icon( imageVector = Icons.Default.Clear, contentDescription = null, ) } }, placeholder = { Text( text = stringResource(Res.string.search_repositories_hint), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, softWrap = false, maxLines = 1, overflow = TextOverflow.Ellipsis, ) }, textStyle = MaterialTheme.typography.bodyLarge.copy( color = MaterialTheme.colorScheme.onSurface, ), keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Text, imeAction = ImeAction.Search, ), keyboardActions = KeyboardActions( onSearch = { onAction(SearchAction.OnSearchImeClick) }, ), singleLine = true, colors = TextFieldDefaults.colors( focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent, focusedContainerColor = MaterialTheme.colorScheme.surfaceContainer, unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainer, ), shape = CircleShape, modifier = Modifier .weight(1f) .focusRequester(focusRequester), ) } } @Preview @Composable private fun Preview() { GithubStoreTheme { SearchScreen( state = SearchState(), snackbarHost = SnackbarHostState(), onAction = {}, ) } } ================================================ FILE: feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchState.kt ================================================ package zed.rainxch.search.presentation import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import zed.rainxch.core.presentation.model.DiscoveryRepositoryUi import zed.rainxch.search.presentation.model.ParsedGithubLink import zed.rainxch.search.presentation.model.ProgrammingLanguageUi import zed.rainxch.search.presentation.model.SearchPlatformUi import zed.rainxch.search.presentation.model.SortByUi import zed.rainxch.search.presentation.model.SortOrderUi data class SearchState( val query: String = "", val repositories: ImmutableList = persistentListOf(), val selectedSearchPlatform: SearchPlatformUi = SearchPlatformUi.All, val selectedSortBy: SortByUi = SortByUi.BestMatch, val selectedSortOrder: SortOrderUi = SortOrderUi.Descending, val selectedLanguage: ProgrammingLanguageUi = ProgrammingLanguageUi.All, val isLoading: Boolean = false, val isLiquidGlassEnabled: Boolean = true, val isHideSeenEnabled: Boolean = false, val seenRepoIds: Set = emptySet(), val isLoadingMore: Boolean = false, val errorMessage: String? = null, val hasMorePages: Boolean = true, val totalCount: Int? = null, val isLanguageSheetVisible: Boolean = false, val isSortByDialogVisible: Boolean = false, val detectedLinks: ImmutableList = persistentListOf(), val clipboardLinks: ImmutableList = persistentListOf(), val isClipboardBannerVisible: Boolean = false, val autoDetectClipboardEnabled: Boolean = true, ) ================================================ FILE: feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt ================================================ package zed.rainxch.search.presentation import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.jetbrains.compose.resources.getString import zed.rainxch.core.domain.logging.GitHubStoreLogger import zed.rainxch.core.domain.model.Platform import zed.rainxch.core.domain.model.RateLimitException import zed.rainxch.core.domain.repository.FavouritesRepository import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.repository.SeenReposRepository import zed.rainxch.core.domain.repository.StarredRepository import zed.rainxch.core.domain.repository.TweaksRepository import zed.rainxch.core.domain.use_cases.SyncInstalledAppsUseCase import zed.rainxch.core.domain.utils.ClipboardHelper import zed.rainxch.core.domain.utils.ShareManager import zed.rainxch.core.presentation.model.DiscoveryRepositoryUi import zed.rainxch.core.presentation.utils.toUi import zed.rainxch.domain.repository.SearchRepository import zed.rainxch.githubstore.core.presentation.res.Res import zed.rainxch.githubstore.core.presentation.res.failed_to_share_link import zed.rainxch.githubstore.core.presentation.res.link_copied_to_clipboard import zed.rainxch.githubstore.core.presentation.res.no_github_link_in_clipboard import zed.rainxch.githubstore.core.presentation.res.no_repositories_found import zed.rainxch.githubstore.core.presentation.res.search_failed import zed.rainxch.search.presentation.mappers.toDomain import zed.rainxch.search.presentation.utils.isEntirelyGithubUrls import zed.rainxch.search.presentation.utils.parseGithubUrls class SearchViewModel( private val searchRepository: SearchRepository, private val installedAppsRepository: InstalledAppsRepository, private val syncInstalledAppsUseCase: SyncInstalledAppsUseCase, private val favouritesRepository: FavouritesRepository, private val starredRepository: StarredRepository, private val logger: GitHubStoreLogger, private val shareManager: ShareManager, private val platform: Platform, private val clipboardHelper: ClipboardHelper, private val tweaksRepository: TweaksRepository, private val seenReposRepository: SeenReposRepository, ) : ViewModel() { private var hasLoadedInitialData = false private var currentSearchJob: Job? = null private var currentPage = 1 private var searchDebounceJob: Job? = null companion object { private const val MIN_QUERY_LENGTH = 3 private const val DEBOUNCE_MS = 800L } private val _state = MutableStateFlow(SearchState()) val state = _state .onStart { if (!hasLoadedInitialData) { syncSystemState() observeInstalledApps() observeFavouriteApps() observeStarredRepos() observeLiquidGlassEnabled() observeSeenRepos() observeHideSeenEnabled() observeClipboardSetting() checkClipboardForLinks() hasLoadedInitialData = true } }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000L), initialValue = SearchState(), ) private fun observeLiquidGlassEnabled() { viewModelScope.launch { tweaksRepository.getLiquidGlassEnabled().collect { enabled -> _state.update { it.copy( isLiquidGlassEnabled = enabled, ) } } } } private fun observeSeenRepos() { viewModelScope.launch { seenReposRepository.getAllSeenRepoIds().collect { ids -> _state.update { current -> current.copy( seenRepoIds = ids, repositories = current.repositories .map { repo -> repo.copy(isSeen = repo.repository.id in ids) }.toImmutableList(), ) } } } } private fun observeHideSeenEnabled() { viewModelScope.launch { tweaksRepository.getHideSeenEnabled().collect { enabled -> _state.update { it.copy(isHideSeenEnabled = enabled) } } } } private val _events = Channel() val events = _events.receiveAsFlow() private fun syncSystemState() { viewModelScope.launch { try { val result = syncInstalledAppsUseCase() if (result.isFailure) { logger.warn("Initial sync had issues: ${result.exceptionOrNull()?.message}") } } catch (e: Exception) { logger.error("Initial sync failed: ${e.message}") } } } private fun observeClipboardSetting() { viewModelScope.launch { tweaksRepository.getAutoDetectClipboardLinks().collect { enabled -> _state.update { current -> current.copy( autoDetectClipboardEnabled = enabled, clipboardLinks = if (enabled) current.clipboardLinks else persistentListOf(), isClipboardBannerVisible = if (enabled) current.isClipboardBannerVisible else false, ) } if (enabled) checkClipboardForLinks() } } } private fun checkClipboardForLinks() { viewModelScope.launch { val enabled = tweaksRepository.getAutoDetectClipboardLinks().first() if (!enabled) return@launch try { val clipText = clipboardHelper.getText() ?: return@launch val links = parseGithubUrls(clipText).toImmutableList() if (links.isNotEmpty()) { _state.update { it.copy( clipboardLinks = links, isClipboardBannerVisible = true, ) } } } catch (e: Exception) { logger.debug("Failed to read clipboard: ${e.message}") } } } private fun observeInstalledApps() { viewModelScope.launch { installedAppsRepository .getAllInstalledApps() .collect { installedApps -> val installedMap = installedApps.associateBy { it.repoId } _state.update { current -> current.copy( repositories = current.repositories .map { searchRepo -> val app = installedMap[searchRepo.repository.id] searchRepo.copy( isInstalled = app != null, isUpdateAvailable = app?.isUpdateAvailable ?: false, ) }.toImmutableList(), ) } } } } private fun observeFavouriteApps() { viewModelScope.launch { favouritesRepository.getAllFavorites().collect { favoriteRepos -> val installedMap = favoriteRepos.associateBy { it.repoId } _state.update { current -> current.copy( repositories = current.repositories .map { searchRepo -> val app = installedMap[searchRepo.repository.id] searchRepo.copy( isFavourite = app != null, ) }.toImmutableList(), ) } } } } private fun observeStarredRepos() { viewModelScope.launch { starredRepository.getAllStarred().collect { starredRepos -> val installedMap = starredRepos.associateBy { it.repoId } _state.update { current -> current.copy( repositories = current.repositories .map { searchRepo -> val app = installedMap[searchRepo.repository.id] searchRepo.copy(isStarred = app != null) }.toImmutableList(), ) } } } } private fun performSearch(isInitial: Boolean = false) { val query = _state.value.query.trim() if (query.isBlank() || query.length < MIN_QUERY_LENGTH) { if (query.isBlank()) { _state.update { it.copy( isLoading = false, isLoadingMore = false, repositories = persistentListOf(), errorMessage = null, totalCount = null, ) } } return } if (isInitial) { currentSearchJob?.cancel() currentPage = 1 } currentSearchJob = viewModelScope.launch { _state.update { it.copy( isLoading = isInitial, isLoadingMore = !isInitial, errorMessage = null, repositories = if (isInitial) { persistentListOf() } else { it.repositories }, totalCount = if (isInitial) null else it.totalCount, ) } try { val installedMap = installedAppsRepository .getAllInstalledApps() .first() .associateBy { it.repoId } val favoritesMap = favouritesRepository .getAllFavorites() .first() .associateBy { it.repoId } val starredReposMap = starredRepository .getAllStarred() .first() .associateBy { it.repoId } searchRepository .searchRepositories( query = _state.value.query, platform = _state.value.selectedSearchPlatform.toDomain(), language = _state.value.selectedLanguage.toDomain(), sortBy = _state.value.selectedSortBy.toDomain(), sortOrder = _state.value.selectedSortOrder.toDomain(), page = currentPage, ).collect { paginatedRepos -> currentPage = paginatedRepos.nextPageIndex val seenIds = _state.value.seenRepoIds val newReposWithStatus = paginatedRepos.repos.map { repo -> val app = installedMap[repo.id] val favourite = favoritesMap[repo.id] val starred = starredReposMap[repo.id] DiscoveryRepositoryUi( isInstalled = app != null, isFavourite = favourite != null, isStarred = starred != null, isSeen = repo.id in seenIds, isUpdateAvailable = app?.isUpdateAvailable ?: false, repository = repo.toUi(), ) } _state.update { currentState -> val mergedMap = LinkedHashMap() currentState.repositories.forEach { r -> mergedMap[r.repository.id] = r } newReposWithStatus.forEach { r -> val existing = mergedMap[r.repository.id] if (existing == null) { mergedMap[r.repository.id] = r } else { mergedMap[r.repository.id] = existing.copy( isInstalled = r.isInstalled, isUpdateAvailable = r.isUpdateAvailable, isFavourite = r.isFavourite, isStarred = r.isStarred, repository = r.repository, ) } } val allRepos = mergedMap.values.toImmutableList() currentState.copy( repositories = allRepos, hasMorePages = paginatedRepos.hasMore, totalCount = allRepos.size, errorMessage = if (allRepos.isEmpty() && !paginatedRepos.hasMore) { getString(Res.string.no_repositories_found) } else { null }, ) } } _state.update { it.copy(isLoading = false, isLoadingMore = false) } } catch (e: RateLimitException) { logger.debug("Rate limit exceeded: ${e.message}") _state.update { it.copy( isLoading = false, isLoadingMore = false, errorMessage = e.message, ) } } catch (e: CancellationException) { logger.debug("Search cancelled (expected): ${e.message}") } catch (e: Exception) { logger.error("Search failed: ${e.message}") _state.update { it.copy( isLoading = false, isLoadingMore = false, errorMessage = e.message ?: getString(Res.string.search_failed), ) } } } } fun onAction(action: SearchAction) { when (action) { is SearchAction.OnPlatformTypeSelected -> { if (_state.value.selectedSearchPlatform != action.searchPlatform) { _state.update { it.copy(selectedSearchPlatform = action.searchPlatform) } currentPage = 1 searchDebounceJob?.cancel() performSearch(isInitial = true) } } is SearchAction.OnLanguageSelected -> { if (_state.value.selectedLanguage != action.language) { _state.update { it.copy(selectedLanguage = action.language) } currentPage = 1 searchDebounceJob?.cancel() performSearch(isInitial = true) } } is SearchAction.OnSearchChange -> { val links = parseGithubUrls(action.query) _state.update { it.copy( query = action.query, detectedLinks = links, ) } searchDebounceJob?.cancel() if (isEntirelyGithubUrls(action.query)) { currentSearchJob?.cancel() _state.update { it.copy( isLoading = false, isLoadingMore = false, errorMessage = null, repositories = persistentListOf(), totalCount = null, ) } return } if (action.query.isBlank()) { _state.update { it.copy( repositories = persistentListOf(), isLoading = false, isLoadingMore = false, errorMessage = null, totalCount = null, ) } } else if (action.query.trim().length < MIN_QUERY_LENGTH) { currentSearchJob?.cancel() _state.update { it.copy( isLoading = false, isLoadingMore = false, errorMessage = null, ) } } else { searchDebounceJob = viewModelScope.launch { try { delay(DEBOUNCE_MS) currentPage = 1 performSearch(isInitial = true) } catch (_: CancellationException) { logger.debug("Debounce cancelled (expected)") } } } } SearchAction.OnToggleLanguageSheetVisibility -> { _state.update { it.copy(isLanguageSheetVisible = !it.isLanguageSheetVisible) } } SearchAction.OnSearchImeClick -> { if (_state.value.detectedLinks.isNotEmpty() && isEntirelyGithubUrls(_state.value.query)) { val link = _state.value.detectedLinks.first() viewModelScope.launch { _events.send(SearchEvent.NavigateToRepo(link.owner, link.repo)) } return } searchDebounceJob?.cancel() currentPage = 1 performSearch(isInitial = true) } is SearchAction.OnShareClick -> { viewModelScope.launch { runCatching { shareManager.shareText("https://github-store.org/app?repo=${action.repo.fullName}") }.onFailure { t -> logger.error("Failed to share link: ${t.message}") _events.send( SearchEvent.OnMessage(getString(Res.string.failed_to_share_link)), ) return@launch } if (platform != Platform.ANDROID) { _events.send(SearchEvent.OnMessage(getString(Res.string.link_copied_to_clipboard))) } } } is SearchAction.OnSortBySelected -> { if (_state.value.selectedSortBy != action.sortBy) { _state.update { it.copy(selectedSortBy = action.sortBy) } currentPage = 1 searchDebounceJob?.cancel() performSearch(isInitial = true) } } is SearchAction.OnSortOrderSelected -> { if (_state.value.selectedSortOrder != action.sortOrder) { _state.update { it.copy(selectedSortOrder = action.sortOrder) } currentPage = 1 searchDebounceJob?.cancel() performSearch(isInitial = true) } } SearchAction.OnToggleSortByDialogVisibility -> { _state.update { it.copy(isSortByDialogVisible = !it.isSortByDialogVisible) } } SearchAction.LoadMore -> { if (!_state.value.isLoadingMore && !_state.value.isLoading && _state.value.hasMorePages) { performSearch(isInitial = false) } } SearchAction.Retry -> { currentPage = 1 searchDebounceJob?.cancel() performSearch(isInitial = true) } SearchAction.OnClearClick -> { _state.update { it.copy( query = "", repositories = persistentListOf(), isLoading = false, isLoadingMore = false, errorMessage = null, totalCount = null, detectedLinks = persistentListOf(), ) } } is SearchAction.OpenGithubLink -> { viewModelScope.launch { _events.send(SearchEvent.NavigateToRepo(action.owner, action.repo)) } } SearchAction.OnFabClick -> { viewModelScope.launch { try { val clipText = clipboardHelper.getText() if (clipText.isNullOrBlank()) { _events.send(SearchEvent.OnMessage(getString(Res.string.no_github_link_in_clipboard))) return@launch } val links = parseGithubUrls(clipText) if (links.isEmpty()) { _events.send(SearchEvent.OnMessage(getString(Res.string.no_github_link_in_clipboard))) return@launch } if (links.size == 1) { _events.send( SearchEvent.NavigateToRepo( links.first().owner, links.first().repo, ), ) } else { _state.update { it.copy( query = clipText, detectedLinks = links, repositories = persistentListOf(), totalCount = null, isLoading = false, isLoadingMore = false, errorMessage = null, ) } } } catch (e: Exception) { logger.error("Failed to read clipboard: ${e.message}") _events.send(SearchEvent.OnMessage(getString(Res.string.no_github_link_in_clipboard))) } } } SearchAction.DismissClipboardBanner -> { _state.update { it.copy(isClipboardBannerVisible = false) } } is SearchAction.OnRepositoryClick -> { // Handled in composable } SearchAction.OnNavigateBackClick -> { // Handled in composable } is SearchAction.OnRepositoryDeveloperClick -> { // Handled in composable } } } override fun onCleared() { super.onCleared() currentSearchJob?.cancel() searchDebounceJob?.cancel() } } ================================================ FILE: feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/components/LanguageFilterBottomSheet.kt ================================================ package zed.rainxch.search.presentation.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.SheetState import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import zed.rainxch.githubstore.core.presentation.res.* import zed.rainxch.search.presentation.model.ProgrammingLanguageUi import zed.rainxch.search.presentation.utils.label @OptIn(ExperimentalMaterial3Api::class) @Composable fun LanguageFilterBottomSheet( selectedLanguage: ProgrammingLanguageUi, onLanguageSelected: (ProgrammingLanguageUi) -> Unit, onDismissRequest: () -> Unit, sheetState: SheetState = rememberModalBottomSheetState(), ) { ModalBottomSheet( onDismissRequest = onDismissRequest, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.surface, tonalElevation = 0.dp, ) { Column( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp) .padding(bottom = 32.dp), ) { Text( text = stringResource(Res.string.filter_by_language), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(bottom = 16.dp), ) LazyVerticalGrid( columns = GridCells.Fixed(2), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth(), ) { items(ProgrammingLanguageUi.entries.toList()) { language -> FilterChip( selected = selectedLanguage == language, onClick = { onLanguageSelected(language) onDismissRequest() }, label = { Text( text = stringResource(language.label()), style = MaterialTheme.typography.bodyMedium, fontWeight = if (selectedLanguage == language) { FontWeight.SemiBold } else { FontWeight.Normal }, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center, ) }, modifier = Modifier.fillMaxWidth(), ) } } } } } ================================================ FILE: feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/components/SortByBottomSheet.kt ================================================ package zed.rainxch.search.presentation.components 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.material3.AlertDialog import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import zed.rainxch.githubstore.core.presentation.res.Res import zed.rainxch.githubstore.core.presentation.res.close import zed.rainxch.githubstore.core.presentation.res.sort_by import zed.rainxch.search.presentation.model.SortByUi import zed.rainxch.search.presentation.model.SortOrderUi import zed.rainxch.search.presentation.utils.label @Composable fun SortByBottomSheet( selectedSortBy: SortByUi, selectedSortOrder: SortOrderUi, onSortBySelected: (SortByUi) -> Unit, onSortOrderSelected: (SortOrderUi) -> Unit, onDismissRequest: () -> Unit, modifier: Modifier = Modifier, ) { AlertDialog( onDismissRequest = onDismissRequest, confirmButton = {}, dismissButton = { TextButton(onClick = onDismissRequest) { Text(text = stringResource(Res.string.close)) } }, title = { Text( text = stringResource(Res.string.sort_by), style = MaterialTheme.typography.titleMedium, ) }, text = { Column( modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp), ) { SortByUi.entries.forEach { option -> val isSelected = option == selectedSortBy TextButton( onClick = { onSortBySelected(option) }, modifier = Modifier.fillMaxWidth(), ) { Text( text = stringResource(option.label()) + if (isSelected) " ✓" else "", style = MaterialTheme.typography.bodyMedium, color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, ) } } HorizontalDivider() Spacer(Modifier.height(4.dp)) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { SortOrderUi.entries.forEach { order -> FilterChip( selected = order == selectedSortOrder, onClick = { onSortOrderSelected(order) }, label = { Text( text = stringResource(order.label()), style = MaterialTheme.typography.bodyMedium, ) }, ) } } } }, ) } ================================================ FILE: feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/mappers/PlatformLanguageMappers.kt ================================================ package zed.rainxch.search.presentation.mappers import zed.rainxch.domain.model.ProgrammingLanguage import zed.rainxch.domain.model.ProgrammingLanguage.* import zed.rainxch.search.presentation.model.ProgrammingLanguageUi fun ProgrammingLanguageUi.toDomain(): ProgrammingLanguage { return when (this) { ProgrammingLanguageUi.All -> All ProgrammingLanguageUi.Kotlin -> Kotlin ProgrammingLanguageUi.Java -> Java ProgrammingLanguageUi.JavaScript -> JavaScript ProgrammingLanguageUi.TypeScript -> TypeScript ProgrammingLanguageUi.Python -> Python ProgrammingLanguageUi.Swift -> Swift ProgrammingLanguageUi.Rust -> Rust ProgrammingLanguageUi.Go -> Go ProgrammingLanguageUi.CSharp -> CSharp ProgrammingLanguageUi.CPlusPlus -> CPlusPlus ProgrammingLanguageUi.C -> C ProgrammingLanguageUi.Dart -> Dart ProgrammingLanguageUi.Ruby -> Ruby ProgrammingLanguageUi.PHP -> PHP } } ================================================ FILE: feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/mappers/SearchPlatformMappers.kt ================================================ package zed.rainxch.search.presentation.mappers import zed.rainxch.core.domain.model.DiscoveryPlatform import zed.rainxch.core.domain.model.DiscoveryPlatform.* import zed.rainxch.search.presentation.model.SearchPlatformUi fun SearchPlatformUi.toDomain(): DiscoveryPlatform = when (this) { SearchPlatformUi.All -> All SearchPlatformUi.Android -> Android SearchPlatformUi.Windows -> Windows SearchPlatformUi.Macos -> Macos SearchPlatformUi.Linux -> Linux } ================================================ FILE: feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/mappers/SortByMappers.kt ================================================ package zed.rainxch.search.presentation.mappers import zed.rainxch.domain.model.SortBy import zed.rainxch.domain.model.SortBy.* import zed.rainxch.search.presentation.model.SortByUi fun SortByUi.toDomain(): SortBy { return when (this) { SortByUi.MostStars -> MostStars SortByUi.MostForks -> MostForks SortByUi.BestMatch -> BestMatch } } ================================================ FILE: feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/mappers/SortOrderMapper.kt ================================================ package zed.rainxch.search.presentation.mappers import zed.rainxch.domain.model.SortOrder import zed.rainxch.domain.model.SortOrder.* import zed.rainxch.search.presentation.model.SortOrderUi fun SortOrderUi.toDomain() : SortOrder { return when (this) { SortOrderUi.Descending -> Descending SortOrderUi.Ascending -> Ascending } } ================================================ FILE: feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/model/ParsedGithubLink.kt ================================================ package zed.rainxch.search.presentation.model data class ParsedGithubLink( val owner: String, val repo: String, val fullUrl: String, ) ================================================ FILE: feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/model/ProgrammingLanguageUi.kt ================================================ package zed.rainxch.search.presentation.model enum class ProgrammingLanguageUi { All, Kotlin, Java, JavaScript, TypeScript, Python, Swift, Rust, Go, CSharp, CPlusPlus, C, Dart, Ruby, PHP, } ================================================ FILE: feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/model/SearchPlatformUi.kt ================================================ package zed.rainxch.search.presentation.model enum class SearchPlatformUi { All, Android, Windows, Macos, Linux, } ================================================ FILE: feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/model/SortByUi.kt ================================================ package zed.rainxch.search.presentation.model enum class SortByUi { MostStars, MostForks, BestMatch, } ================================================ FILE: feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/model/SortOrderUi.kt ================================================ package zed.rainxch.search.presentation.model enum class SortOrderUi { Descending, Ascending, } ================================================ FILE: feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/utils/GithubUrlParser.kt ================================================ package zed.rainxch.search.presentation.utils import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import zed.rainxch.search.presentation.model.ParsedGithubLink private val GITHUB_URL_REGEX = Regex( """(? = GITHUB_URL_REGEX .findAll(text) .map { match -> ParsedGithubLink( owner = match.groupValues[1], repo = match.groupValues[2].removeSuffix(".git"), fullUrl = "https://github.com/${match.groupValues[1]}/${match.groupValues[2].removeSuffix(".git")}", ) }.distinctBy { "${it.owner}/${it.repo}" } .toImmutableList() fun isEntirelyGithubUrls(text: String): Boolean { val stripped = text .replace(GITHUB_URL_REGEX, "") .replace(Regex("""[\s,;]+"""), "") return stripped.isEmpty() && parseGithubUrls(text).isNotEmpty() } ================================================ FILE: feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/utils/ProgrammingLanguageMapper.kt ================================================ package zed.rainxch.search.presentation.utils import org.jetbrains.compose.resources.StringResource import zed.rainxch.githubstore.core.presentation.res.* import zed.rainxch.search.presentation.model.ProgrammingLanguageUi import zed.rainxch.search.presentation.model.ProgrammingLanguageUi.* fun ProgrammingLanguageUi.label(): StringResource = when (this) { All -> Res.string.language_all Kotlin -> Res.string.language_kotlin Java -> Res.string.language_java JavaScript -> Res.string.language_javascript TypeScript -> Res.string.language_typescript Python -> Res.string.language_python Swift -> Res.string.language_swift Rust -> Res.string.language_rust Go -> Res.string.language_go CSharp -> Res.string.language_csharp CPlusPlus -> Res.string.language_cpp C -> Res.string.language_c Dart -> Res.string.language_dart Ruby -> Res.string.language_ruby PHP -> Res.string.language_php } ================================================ FILE: feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/utils/SortByMapper.kt ================================================ package zed.rainxch.search.presentation.utils import org.jetbrains.compose.resources.StringResource import zed.rainxch.githubstore.core.presentation.res.* import zed.rainxch.search.presentation.model.SortByUi import zed.rainxch.search.presentation.model.SortByUi.* fun SortByUi.label(): StringResource = when (this) { MostStars -> Res.string.sort_most_stars MostForks -> Res.string.sort_most_forks BestMatch -> Res.string.sort_best_match } ================================================ FILE: feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/utils/SortOrderMapper.kt ================================================ package zed.rainxch.search.presentation.utils import org.jetbrains.compose.resources.StringResource import zed.rainxch.githubstore.core.presentation.res.* import zed.rainxch.search.presentation.model.SortOrderUi import zed.rainxch.search.presentation.model.SortOrderUi.* fun SortOrderUi.label(): StringResource = when (this) { Descending -> Res.string.sort_order_descending Ascending -> Res.string.sort_order_ascending } ================================================ FILE: feature/starred/CLAUDE.md ================================================ # CLAUDE.md - Starred Feature ## Purpose Displays the user's locally saved starred repositories. This is a **presentation-only** feature with no domain or data layer -- it uses `StarredRepository` from `core/domain` directly. ## Module Structure ``` feature/starred/ └── presentation/ ├── StarredReposViewModel.kt # Observes starred repos, handles remove ├── StarredReposState.kt # starred list, loading ├── StarredReposAction.kt # RemoveStarred, click actions ├── StarredReposRoot.kt # Main composable (list of starred repos) ├── model/StarredRepositoryUi.kt # UI model for display ├── mappers/StarredRepoToUiMapper.kt # Domain → UI model mapper ├── utils/TimeFormatUtils.kt # Time formatting utilities └── components/StarredRepositoryItem.kt # Individual starred repo card ``` ## Key Dependencies - `StarredRepository` (from `core/domain`) - CRUD operations for starred repos - Starred repos are stored locally in Room database (`StarredRepoDao` in `core/data`) ## Navigation Route: `GithubStoreGraph.StarredReposScreen` (data object, no params) ## Implementation Notes - No network calls -- all data is local (Room database) - Uses a presentation-layer `StarredRepositoryUi` model mapped from the domain `StarredRepository` entity - Starring happens in other features (home, details, search); this feature only displays and removes - Includes its own `TimeFormatUtils` for formatting timestamps on starred items - The Koin module for this feature is registered in `composeApp/.../app/di/ViewModelsModule.kt` since there's no `data/di/` layer ================================================ FILE: feature/starred/data/.gitignore ================================================ /build ================================================ FILE: feature/starred/data/build.gradle.kts ================================================ plugins { alias(libs.plugins.convention.kmp.library) alias(libs.plugins.convention.buildkonfig) } kotlin { sourceSets { commonMain { dependencies { implementation(libs.kotlin.stdlib) implementation(projects.core.domain) implementation(projects.feature.starred.domain) } } androidMain { dependencies { } } jvmMain { dependencies { } } } } ================================================ FILE: feature/starred/data/src/androidMain/AndroidManifest.xml ================================================ ================================================ FILE: feature/starred/domain/.gitignore ================================================ /build ================================================ FILE: feature/starred/domain/build.gradle.kts ================================================ plugins { alias(libs.plugins.convention.kmp.library) } kotlin { sourceSets { commonMain { dependencies { implementation(libs.kotlin.stdlib) implementation(projects.core.domain) } } androidMain { dependencies { } } jvmMain { dependencies { } } } } ================================================ FILE: feature/starred/domain/src/androidMain/AndroidManifest.xml ================================================ ================================================ FILE: feature/starred/presentation/.gitignore ================================================ /build ================================================ FILE: feature/starred/presentation/build.gradle.kts ================================================ plugins { alias(libs.plugins.convention.cmp.feature) } kotlin { sourceSets { commonMain { dependencies { implementation(libs.kotlin.stdlib) implementation(projects.core.domain) implementation(projects.core.presentation) implementation(projects.feature.starred.domain) implementation(libs.bundles.landscapist) implementation(libs.kotlinx.collections.immutable) implementation(libs.androidx.compose.ui.tooling.preview) implementation(compose.components.resources) } } androidMain { dependencies { } } jvmMain { dependencies { } } } } ================================================ FILE: feature/starred/presentation/src/androidMain/AndroidManifest.xml ================================================ ================================================ FILE: feature/starred/presentation/src/commonMain/kotlin/zed/rainxch/starred/presentation/StarredReposAction.kt ================================================ package zed.rainxch.starred.presentation import zed.rainxch.starred.presentation.model.StarredRepositoryUi sealed interface StarredReposAction { data object OnNavigateBackClick : StarredReposAction data object OnRefresh : StarredReposAction data object OnRetrySync : StarredReposAction data object OnSignInClick : StarredReposAction data object OnDismissError : StarredReposAction data class OnRepositoryClick( val repository: StarredRepositoryUi, ) : StarredReposAction data class OnDeveloperProfileClick( val username: String, ) : StarredReposAction data class OnToggleFavorite( val repository: StarredRepositoryUi, ) : StarredReposAction } ================================================ FILE: feature/starred/presentation/src/commonMain/kotlin/zed/rainxch/starred/presentation/StarredReposRoot.kt ================================================ @file:OptIn(ExperimentalTime::class) package zed.rainxch.starred.presentation 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.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells import androidx.compose.foundation.lazy.staggeredgrid.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Star import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Snackbar import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.collections.immutable.persistentListOf import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import zed.rainxch.core.presentation.components.GithubStoreButton import zed.rainxch.core.presentation.theme.GithubStoreTheme import zed.rainxch.githubstore.core.presentation.res.* import zed.rainxch.starred.presentation.components.StarredRepositoryItem import zed.rainxch.starred.presentation.utils.formatRelativeTime import kotlin.time.ExperimentalTime @Composable fun StarredReposRoot( onNavigateBack: () -> Unit, onNavigateToDetails: (repoId: Long) -> Unit, onNavigateToDeveloperProfile: (username: String) -> Unit, onNavigateToAuthentication: () -> Unit, viewModel: StarredReposViewModel = koinViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() StarredScreen( state = state, onAction = { action -> when (action) { StarredReposAction.OnNavigateBackClick -> onNavigateBack() is StarredReposAction.OnRepositoryClick -> onNavigateToDetails(action.repository.repoId) is StarredReposAction.OnDeveloperProfileClick -> onNavigateToDeveloperProfile(action.username) StarredReposAction.OnSignInClick -> onNavigateToAuthentication() else -> viewModel.onAction(action) } }, ) } @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable fun StarredScreen( state: StarredReposState, onAction: (StarredReposAction) -> Unit, ) { val pullRefreshState = rememberPullToRefreshState() Scaffold( topBar = { StarredTopBar( lastSyncTime = state.lastSyncTime, isSyncing = state.isSyncing, onAction = onAction, ) }, containerColor = MaterialTheme.colorScheme.background, ) { innerPadding -> Box( modifier = Modifier .fillMaxSize() .padding(innerPadding), ) { when { !state.isAuthenticated -> { EmptyStateContent( title = stringResource(Res.string.sign_in_required), message = stringResource(Res.string.sign_in_with_github_for_stars), icon = Icons.Default.Star, actionText = stringResource(Res.string.sign_in_with_github), onActionClick = { onAction(StarredReposAction.OnSignInClick) }, modifier = Modifier.align(Alignment.Center), ) } state.isLoading -> { CircularWavyProgressIndicator( modifier = Modifier.align(Alignment.Center), ) } state.starredRepositories.isEmpty() && !state.isSyncing -> { EmptyStateContent( title = stringResource(Res.string.no_starred_repos), message = stringResource(Res.string.star_repos_hint), icon = Icons.Default.Star, actionText = if (state.errorMessage != null) stringResource(Res.string.retry) else null, onActionClick = if (state.errorMessage != null) { { onAction(StarredReposAction.OnRetrySync) } } else { null }, modifier = Modifier.align(Alignment.Center), ) } else -> { PullToRefreshBox( isRefreshing = state.isSyncing, onRefresh = { onAction(StarredReposAction.OnRefresh) }, state = pullRefreshState, modifier = Modifier.fillMaxSize(), ) { LazyVerticalStaggeredGrid( columns = StaggeredGridCells.Adaptive(350.dp), verticalItemSpacing = 12.dp, horizontalArrangement = Arrangement.spacedBy(12.dp), contentPadding = PaddingValues(horizontal = 8.dp, vertical = 12.dp), modifier = Modifier.fillMaxSize(), ) { items( items = state.starredRepositories, key = { it.repoId }, ) { repo -> StarredRepositoryItem( repository = repo, onToggleFavoriteClick = { onAction(StarredReposAction.OnToggleFavorite(repo)) }, onItemClick = { onAction(StarredReposAction.OnRepositoryClick(repo)) }, onDevProfileClick = { onAction(StarredReposAction.OnDeveloperProfileClick(repo.repoOwner)) }, modifier = Modifier.animateItem(), ) } } } } } state.errorMessage?.let { message -> Snackbar( modifier = Modifier .align(Alignment.BottomCenter) .padding(16.dp), action = { TextButton( onClick = { onAction(StarredReposAction.OnRetrySync) }, ) { Text( text = stringResource(Res.string.retry), ) } }, dismissAction = { IconButton( onClick = { onAction(StarredReposAction.OnDismissError) }, ) { Icon( imageVector = Icons.Default.Close, contentDescription = stringResource(Res.string.dismiss), ) } }, ) { Text( text = message, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, ) } } } } } @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable private fun StarredTopBar( lastSyncTime: Long?, isSyncing: Boolean, onAction: (StarredReposAction) -> Unit, ) { Column { TopAppBar( title = { Column { Text( text = stringResource(Res.string.starred_repositories), style = MaterialTheme.typography.titleMediumEmphasized, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface, ) if (lastSyncTime != null && !isSyncing) { Text( text = "${stringResource(Res.string.last_synced)}:" + " ${formatRelativeTime(lastSyncTime)}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } }, navigationIcon = { IconButton( shapes = IconButtonDefaults.shapes(), onClick = { onAction(StarredReposAction.OnNavigateBackClick) }, ) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.navigate_back), modifier = Modifier.size(24.dp), ) } }, actions = { if (isSyncing) { CircularProgressIndicator( modifier = Modifier .size(24.dp) .padding(end = 12.dp), strokeWidth = 2.dp, ) } }, ) } } @Composable private fun EmptyStateContent( title: String, message: String, icon: ImageVector, modifier: Modifier = Modifier, actionText: String? = null, onActionClick: (() -> Unit)? = null, ) { Column( modifier = modifier.padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { Icon( imageVector = icon, contentDescription = null, modifier = Modifier.size(64.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), ) Spacer(modifier = Modifier.height(16.dp)) Text( text = title, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, ) Spacer(modifier = Modifier.height(8.dp)) Text( text = message, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, ) if (actionText != null && onActionClick != null) { Spacer(modifier = Modifier.height(16.dp)) GithubStoreButton( text = actionText, onClick = onActionClick, ) } } } @Preview @Composable private fun PreviewStarred() { GithubStoreTheme { StarredScreen( state = StarredReposState( starredRepositories = persistentListOf(), isAuthenticated = true, ), onAction = {}, ) } } ================================================ FILE: feature/starred/presentation/src/commonMain/kotlin/zed/rainxch/starred/presentation/StarredReposState.kt ================================================ package zed.rainxch.starred.presentation import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import zed.rainxch.starred.presentation.model.StarredRepositoryUi data class StarredReposState( val starredRepositories: ImmutableList = persistentListOf(), val isLoading: Boolean = false, val isSyncing: Boolean = false, val errorMessage: String? = null, val lastSyncTime: Long? = null, val isAuthenticated: Boolean = false, ) ================================================ FILE: feature/starred/presentation/src/commonMain/kotlin/zed/rainxch/starred/presentation/StarredReposViewModel.kt ================================================ @file:OptIn(ExperimentalTime::class) package zed.rainxch.starred.presentation import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.jetbrains.compose.resources.getString import zed.rainxch.core.domain.model.FavoriteRepo import zed.rainxch.core.domain.repository.AuthenticationState import zed.rainxch.core.domain.repository.FavouritesRepository import zed.rainxch.core.domain.repository.StarredRepository import zed.rainxch.githubstore.core.presentation.res.* import zed.rainxch.starred.presentation.mappers.toStarredRepositoryUi import kotlin.time.Clock import kotlin.time.ExperimentalTime class StarredReposViewModel( private val authenticationState: AuthenticationState, private val starredRepository: StarredRepository, private val favouritesRepository: FavouritesRepository, ) : ViewModel() { private var hasLoadedInitialData = false private val _state = MutableStateFlow(StarredReposState()) val state = _state .onStart { if (!hasLoadedInitialData) { checkAuthAndLoad() hasLoadedInitialData = true } }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000L), initialValue = StarredReposState(), ) private fun checkAuthAndLoad() { viewModelScope.launch { val isAuthenticated = authenticationState.isCurrentlyUserLoggedIn() _state.update { it.copy(isAuthenticated = isAuthenticated) } if (isAuthenticated) { loadStarredRepos() syncIfNeeded() } } } private fun loadStarredRepos() { viewModelScope.launch { combine( starredRepository.getAllStarred(), favouritesRepository.getAllFavorites(), ) { starred, favorites -> val favoriteIds = favorites.map { it.repoId }.toSet() starred.map { it.toStarredRepositoryUi( isFavorite = favoriteIds.contains(it.repoId), ) } }.flowOn(Dispatchers.Default) .collect { starredRepos -> _state.update { it.copy( starredRepositories = starredRepos.toImmutableList(), isLoading = false, ) } } } } private fun syncIfNeeded() { viewModelScope.launch { if (starredRepository.needsSync()) { syncStarredRepos() } else { val lastSync = starredRepository.getLastSyncTime() _state.update { it.copy(lastSyncTime = lastSync) } } } } private fun syncStarredRepos(forceRefresh: Boolean = false) { viewModelScope.launch { _state.update { it.copy(isSyncing = true, errorMessage = null) } val result = starredRepository.syncStarredRepos(forceRefresh) result .onSuccess { val lastSync = starredRepository.getLastSyncTime() _state.update { it.copy( isSyncing = false, lastSyncTime = lastSync, ) } }.onFailure { error -> _state.update { it.copy( isSyncing = false, errorMessage = error.message ?: getString(Res.string.sync_starred_failed), ) } } } } fun onAction(action: StarredReposAction) { when (action) { StarredReposAction.OnNavigateBackClick -> { // Handled in composable } is StarredReposAction.OnRepositoryClick -> { // Handled in composable } is StarredReposAction.OnDeveloperProfileClick -> { // Handled in composable } is StarredReposAction.OnSignInClick -> { // Handled in composable } StarredReposAction.OnRefresh -> { syncStarredRepos(forceRefresh = true) } StarredReposAction.OnRetrySync -> { syncStarredRepos(forceRefresh = true) } StarredReposAction.OnDismissError -> { _state.update { it.copy(errorMessage = null) } } is StarredReposAction.OnToggleFavorite -> { viewModelScope.launch { val repo = action.repository val favoriteRepo = FavoriteRepo( repoId = repo.repoId, repoName = repo.repoName, repoOwner = repo.repoOwner, repoOwnerAvatarUrl = repo.repoOwnerAvatarUrl, repoDescription = repo.repoDescription, primaryLanguage = repo.primaryLanguage, repoUrl = repo.repoUrl, latestVersion = repo.latestRelease, latestReleaseUrl = repo.latestReleaseUrl, addedAt = Clock.System.now().toEpochMilliseconds(), lastSyncedAt = Clock.System.now().toEpochMilliseconds(), ) favouritesRepository.toggleFavorite(favoriteRepo) } } } } } ================================================ FILE: feature/starred/presentation/src/commonMain/kotlin/zed/rainxch/starred/presentation/components/StarredRepositoryItem.kt ================================================ package zed.rainxch.starred.presentation.components import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column 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.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.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.CallSplit import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.outlined.FavoriteBorder import androidx.compose.material.icons.outlined.Warning import androidx.compose.material3.Badge import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilledIconToggleButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialShapes import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SuggestionChip import androidx.compose.material3.Text import androidx.compose.material3.toShape 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.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.skydoves.landscapist.ImageOptions import com.skydoves.landscapist.coil3.CoilImage import org.jetbrains.compose.resources.stringResource import zed.rainxch.core.presentation.components.ExpressiveCard import zed.rainxch.core.presentation.theme.GithubStoreTheme import zed.rainxch.core.presentation.utils.formatCount import zed.rainxch.githubstore.core.presentation.res.* import zed.rainxch.starred.presentation.model.StarredRepositoryUi @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun StarredRepositoryItem( repository: StarredRepositoryUi, onToggleFavoriteClick: () -> Unit, onItemClick: () -> Unit, onDevProfileClick: () -> Unit, modifier: Modifier = Modifier, ) { ExpressiveCard( onClick = onItemClick, modifier = modifier.fillMaxWidth(), ) { Column( modifier = Modifier .fillMaxWidth() .padding(16.dp), ) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { CoilImage( imageModel = { repository.repoOwnerAvatarUrl }, modifier = Modifier .size(40.dp) .clip(CircleShape) .clickable(onClick = { onDevProfileClick() }), imageOptions = ImageOptions( contentScale = ContentScale.Crop, ), ) Spacer(modifier = Modifier.width(12.dp)) Column( modifier = Modifier .weight(1f) .clickable(onClick = { onDevProfileClick() }), ) { Text( text = repository.repoName, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Text( text = repository.repoOwner, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } FilledIconToggleButton( checked = repository.isFavorite, onCheckedChange = { onToggleFavoriteClick() }, modifier = Modifier.size(40.dp), shape = MaterialShapes.Cookie6Sided.toShape(), ) { Icon( imageVector = if (repository.isFavorite) { Icons.Filled.Favorite } else { Icons.Outlined.FavoriteBorder }, contentDescription = if (repository.isFavorite) { stringResource(Res.string.remove_from_favourites) } else { stringResource(Res.string.add_to_favourites) }, modifier = Modifier.size(20.dp), ) } } repository.repoDescription?.let { description -> Spacer(modifier = Modifier.height(12.dp)) Text( text = description, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 3, overflow = TextOverflow.Ellipsis, ) } Spacer(modifier = Modifier.height(12.dp)) Row( modifier = Modifier .fillMaxWidth() .horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically, ) { StatChip( icon = Icons.Default.Star, label = formatCount(repository.stargazersCount), contentDescription = "${repository.stargazersCount} ${stringResource(Res.string.stars)}", ) StatChip( icon = Icons.AutoMirrored.Filled.CallSplit, label = formatCount(repository.forksCount), contentDescription = "${repository.forksCount} ${stringResource(Res.string.forks)}", ) if (repository.openIssuesCount > 0) { StatChip( icon = Icons.Outlined.Warning, label = formatCount(repository.openIssuesCount), contentDescription = "${repository.openIssuesCount} ${stringResource(Res.string.issues)}", ) } repository.primaryLanguage?.let { language -> SuggestionChip( onClick = {}, label = { Text( text = language, style = MaterialTheme.typography.labelSmall, ) }, modifier = Modifier.height(32.dp), ) } } if (repository.isInstalled || repository.latestRelease != null) { Spacer(modifier = Modifier.height(12.dp)) FlowRow( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { if (repository.isInstalled) { Badge( containerColor = MaterialTheme.colorScheme.primaryContainer, ) { Text( text = stringResource(Res.string.installed), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onPrimaryContainer, ) } } repository.latestRelease?.let { version -> Badge( containerColor = MaterialTheme.colorScheme.secondaryContainer, ) { Text( text = version, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSecondaryContainer, ) } } } } } } } @Composable private fun StatChip( icon: ImageVector, label: String, contentDescription: String, modifier: Modifier = Modifier, ) { Row( modifier = modifier, horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( text = label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, ) } } @Preview @Composable private fun PreviewStarredRepoItem() { GithubStoreTheme { StarredRepositoryItem( repository = StarredRepositoryUi( repoId = 1, repoName = "awesome-app", repoOwner = "developer", repoOwnerAvatarUrl = "", repoDescription = "An awesome application that does amazing things", primaryLanguage = "Kotlin", repoUrl = "", stargazersCount = 1234, forksCount = 567, openIssuesCount = 12, isInstalled = true, isFavorite = false, latestRelease = "v1.2.3", latestReleaseUrl = null, starredAt = null, ), onToggleFavoriteClick = {}, onItemClick = {}, onDevProfileClick = {}, ) } } ================================================ FILE: feature/starred/presentation/src/commonMain/kotlin/zed/rainxch/starred/presentation/mappers/StarredRepoToUiMapper.kt ================================================ package zed.rainxch.starred.presentation.mappers import zed.rainxch.core.domain.model.StarredRepository import zed.rainxch.starred.presentation.model.StarredRepositoryUi fun StarredRepository.toStarredRepositoryUi(isFavorite: Boolean = false) = StarredRepositoryUi( repoId = repoId, repoName = repoName, repoOwner = repoOwner, repoOwnerAvatarUrl = repoOwnerAvatarUrl, repoDescription = repoDescription, primaryLanguage = primaryLanguage, repoUrl = repoUrl, stargazersCount = stargazersCount, forksCount = forksCount, openIssuesCount = openIssuesCount, isInstalled = isInstalled, isFavorite = isFavorite, latestRelease = latestVersion, latestReleaseUrl = latestReleaseUrl, starredAt = starredAt, ) ================================================ FILE: feature/starred/presentation/src/commonMain/kotlin/zed/rainxch/starred/presentation/model/StarredRepositoryUi.kt ================================================ package zed.rainxch.starred.presentation.model data class StarredRepositoryUi( val repoId: Long, val repoName: String, val repoOwner: String, val repoOwnerAvatarUrl: String, val repoDescription: String?, val primaryLanguage: String?, val repoUrl: String, val stargazersCount: Int, val forksCount: Int, val openIssuesCount: Int, val isInstalled: Boolean, val isFavorite: Boolean = false, val latestRelease: String?, val latestReleaseUrl: String?, val starredAt: Long?, ) ================================================ FILE: feature/starred/presentation/src/commonMain/kotlin/zed/rainxch/starred/presentation/utils/TimeFormatUtils.kt ================================================ package zed.rainxch.starred.presentation.utils import androidx.compose.runtime.Composable import org.jetbrains.compose.resources.stringResource import zed.rainxch.githubstore.core.presentation.res.* import kotlin.time.Clock @Composable internal fun formatRelativeTime(timestamp: Long): String { val now = Clock.System.now().toEpochMilliseconds() val diff = now - timestamp return when { diff < 60_000 -> stringResource(Res.string.just_now) diff < 3600_000 -> stringResource(Res.string.minutes_ago, diff / 60_000) diff < 86400_000 -> stringResource(Res.string.hours_ago, diff / 3600_000) else -> stringResource(Res.string.days_ago, diff / 86400_000) } } ================================================ FILE: gradle/libs.versions.toml ================================================ [versions] agp = "8.13.2" composeBom = "2025.07.00" kermit = "2.0.8" kotlin = "2.3.10" ksp = "2.3.5" androidDesugarJdkLibs = "2.1.5" androidTools = "32.0.1" # Compose compose-hot-reload = "1.0.0" compose-multiplatform = "1.10.1" compose-lifecycle = "2.9.6" navigation-compose = "2.9.2" jetbrains-core-bundle = "1.0.1" material-icons = "1.7.3" # AndroidX androidx-activity = "1.12.4" core-splashscreen = "1.2.0" # Kotlinx kotlinx-coroutines = "1.10.2" kotlinx-serialization = "1.10.0" kotlinx-datetime = "0.7.1" kotlinx-collections-immutable = "0.4.0" # Third party koin = "4.1.1" ktor = "3.4.0" room = "2.8.4" slf4jSimple = "2.0.17" sqlite = "2.6.2" datastore = "1.2.0" compose-jetbrains = "1.10.1" compose-jetbrains-material-you = "1.11.0-alpha03" jetbrains-savedstate = "1.4.0" moko = "0.20.1" buildkonfig = "0.17.1" markdownRenderer = "0.39.2" liquid = "1.1.1" landscapist = "2.9.5" shizuku = "13.1.5" hidden-api = "4.4.0" jsystemthemedetector = "3.9.1" work = "2.11.1" resources="1.10.1" ktlint-gradle = "12.1.1" projectApplicationId = "zed.rainxch.githubstore" projectVersionName = "1.6.2" projectMinSdkVersion = "26" projectTargetSdkVersion = "36" projectCompileSdkVersion = "36" projectVersionCode = "13" [libraries] # Kotlin androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" } kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } # AndroidX Core androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } jetbrains-compose-viewmodel = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "compose-lifecycle" } koin-core-viewmodel = { group = "io.insert-koin", name = "koin-core-viewmodel", version.ref = "koin" } core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "core-splashscreen" } # Kotlinx kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinx-collections-immutable" } # Compose jetbrains-compose-navigation = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigation-compose" } # Koin koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" } koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" } koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" } koin-androidx-compose = { group = "io.insert-koin", name = "koin-androidx-compose", version.ref = "koin" } koin-androidx-navigation = { group = "io.insert-koin", name = "koin-androidx-navigation", version.ref = "koin" } jsystemthemedetector = { module = "com.github.Dansoftowner:jSystemThemeDetector", version.ref = "jsystemthemedetector" } # Ktor ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } ktor-client-auth = { module = "io.ktor:ktor-client-auth", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } # DataStore datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } # Room androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } slf4j-simple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4jSimple" } sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" } # Image loading landscapist-image = { module = "com.github.skydoves:landscapist-coil3", version.ref = "landscapist" } landscapist-core = { module = "com.github.skydoves:landscapist-core", version.ref = "landscapist" } #Permission handling moko-permissions = { module = "dev.icerock.moko:permissions", version.ref = "moko" } moko-permissions-compose = { module = "dev.icerock.moko:permissions-compose", version.ref = "moko" } moko-permissions-notifications = { module = "dev.icerock.moko:permissions-notifications", version.ref = "moko" } android-desugarJdkLibs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "androidDesugarJdkLibs" } android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "agp" } android-tools-common = { group = "com.android.tools", name = "common", version.ref = "androidTools" } compose-gradlePlugin = { group = "org.jetbrains.kotlin", name = "compose-compiler-gradle-plugin", version.ref = "kotlin" } kotlin-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } androidx-room-gradle-plugin = { module = "androidx.room:room-gradle-plugin", version.ref = "room" } buildkonfig-gradlePlugin = { group = "com.codingfeline.buildkonfig", name = "buildkonfig-gradle-plugin", version.ref = "buildkonfig" } buildkonfig-compiler = { group = "com.codingfeline.buildkonfig", name = "buildkonfig-compiler", version.ref = "buildkonfig" } touchlab-kermit = { module = "co.touchlab:kermit", version.ref = "kermit" } androidx-compose-ui-tooling-preview = { group = "org.jetbrains.compose.ui", name = "ui-tooling-preview", version.ref="resources" } androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } koin-bom = { group = "io.insert-koin", name = "koin-bom", version.ref = "koin" } jetbrains-compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose-jetbrains" } jetbrains-compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "compose-jetbrains-material-you" } jetbrains-compose-material-icons-core = { module = "org.jetbrains.compose.material:material-icons-core", version.ref = "material-icons" } jetbrains-compose-material-icons-extended = { module = "org.jetbrains.compose.material:material-icons-extended", version.ref = "material-icons" } jetbrains-compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "compose-jetbrains" } jetbrains-compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "compose-jetbrains" } jetbrains-lifecycle-viewmodel = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel", version.ref = "compose-lifecycle" } jetbrains-lifecycle-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "compose-lifecycle" } jetbrains-lifecycle-viewmodel-savedstate = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate", version.ref = "compose-lifecycle" } jetbrains-savedstate = { module = "org.jetbrains.androidx.savedstate:savedstate", version.ref = "jetbrains-savedstate" } jetbrains-bundle = { module = "org.jetbrains.androidx.core:core-bundle", version.ref = "jetbrains-core-bundle" } # Markdown markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "markdownRenderer" } markdown-renderer-coil3 = { module = "com.mikepenz:multiplatform-markdown-renderer-coil3", version.ref = "markdownRenderer" } # WorkManager androidx-work-runtime = { module = "androidx.work:work-runtime-ktx", version.ref = "work" } # Shizuku shizuku-api = { module = "dev.rikka.shizuku:api", version.ref = "shizuku" } shizuku-provider = { module = "dev.rikka.shizuku:provider", version.ref = "shizuku" } hidden-api-stub = { module = "dev.rikka.hidden:stub", version.ref = "hidden-api" } # Liquid effect liquid = { module = "io.github.fletchmckee.liquid:liquid", version.ref = "liquid" } ktlint-gradlePlugin = { group = "org.jlleitschuh.gradle", name="ktlint-gradle", version.ref = "ktlint-gradle" } [plugins] convention-cmp-application = { id = "zed.rainxch.convention.cmp.application", version = "unspecified" } convention-kmp-library = { id = "zed.rainxch.convention.kmp.library", version = "unspecified" } convention-cmp-library = { id = "zed.rainxch.convention.cmp.library", version = "unspecified" } convention-cmp-feature = { id = "zed.rainxch.convention.cmp.feature", version = "unspecified" } convention-room = { id = "zed.rainxch.convention.room", version = "unspecified" } convention-buildkonfig = { id = "zed.rainxch.convention.buildkonfig", version="unspecified" } # Android android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } android-kotlin-multiplatform-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } # Kotlin kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } # Compose compose-hot-reload = { id = "org.jetbrains.compose.hot-reload", version.ref = "compose-hot-reload" } compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } # Build tools ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } room = { id = "androidx.room", version.ref = "room" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } buildkonfig = { id = "com.codingfeline.buildkonfig", version.ref = "buildkonfig" } [bundles] koin-common = [ "koin-core", "koin-compose", "koin-compose-viewmodel" ] ktor-common = [ "ktor-client-core", "ktor-client-content-negotiation", "ktor-serialization-kotlinx-json", "ktor-client-auth", "ktor-client-logging" ] landscapist = [ "landscapist-core", "landscapist-image" ] ================================================ FILE: gradle/wrapper/gradle-wrapper.properties ================================================ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists ================================================ FILE: gradle.properties ================================================ #Kotlin kotlin.code.style=official kotlin.daemon.jvmargs=-Xmx3072M #Gradle org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8 org.gradle.configuration-cache=true org.gradle.caching=true org.gradle.parallel=true #Android android.nonTransitiveRClass=true android.useAndroidX=true compose.desktop.packaging.checkJdkVendor=false ================================================ 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: packaging/flatpak/README.md ================================================ # Flatpak Packaging for GitHub Store ## Prerequisites Install Flatpak and the build tools: ```bash # Fedora sudo dnf install flatpak flatpak-builder # Ubuntu/Debian sudo apt install flatpak flatpak-builder # Arch sudo pacman -S flatpak flatpak-builder ``` Install the required runtimes: ```bash flatpak install flathub org.freedesktop.Platform//24.08 flatpak install flathub org.freedesktop.Sdk//24.08 flatpak install flathub org.freedesktop.Sdk.Extension.openjdk21//24.08 ``` ## Setup (One-Time) ### 1. Generate Gradle dependency sources Flatpak builds run without network access, so all Maven/Gradle dependencies must be pre-downloaded and listed in a JSON manifest. Add the plugin to your root `build.gradle.kts`: ```kotlin plugins { id("io.github.jwharm.flatpak-gradle-generator") version "1.7.0" } ``` Then generate the sources file: ```bash ./gradlew flatpakGradleGenerator --no-configuration-cache ``` This creates `flatpak-sources.json` in the project root. Move it to this directory: ```bash mv flatpak-sources.json packaging/flatpak/ ``` ### 2. Verify SHA256 hashes The manifest uses pre-computed SHA256 hashes. To verify or update them: ```bash # Gradle distribution curl -sL https://services.gradle.org/distributions/gradle-8.14.3-bin.zip | sha256sum # JBR x64 (check latest at https://github.com/JetBrains/JetBrainsRuntime/releases) curl -sL https://cache-redirector.jetbrains.com/intellij-jbr/jbr-21.0.10-linux-x64-b1163.105.tar.gz | sha256sum # JBR aarch64 curl -sL https://cache-redirector.jetbrains.com/intellij-jbr/jbr-21.0.10-linux-aarch64-b1163.105.tar.gz | sha256sum ``` ### 3. Update screenshot URLs Edit `zed.rainxch.githubstore.metainfo.xml` to point to hosted screenshot images. Flathub requires at least one screenshot with a publicly accessible URL. ## Building Locally ```bash cd packaging/flatpak # Build flatpak-builder --force-clean build-dir zed.rainxch.githubstore.yml # Test run flatpak-builder --run build-dir zed.rainxch.githubstore.yml githubstore # Install locally flatpak-builder --user --install --force-clean build-dir zed.rainxch.githubstore.yml ``` ## Validating ```bash # Validate AppStream metainfo flatpak run org.freedesktop.appstream-glib validate zed.rainxch.githubstore.metainfo.xml # Lint manifest (requires org.flatpak.Builder) flatpak run --command=flatpak-builder-lint org.flatpak.Builder manifest zed.rainxch.githubstore.yml ``` ## Publishing to Flathub 1. Fork `https://github.com/flathub/flathub` 2. Checkout the `new-pr` branch 3. Copy the manifest YAML and `flatpak-sources.json` to the repo root 4. Open a PR titled "Add zed.rainxch.githubstore" 5. Reviewers will trigger test builds with `bot, build` 6. After approval, you get write access to `flathub/zed.rainxch.githubstore` ## File Reference | File | Purpose | |------|---------| | `zed.rainxch.githubstore.yml` | Flatpak build manifest | | `zed.rainxch.githubstore.desktop` | Desktop launcher entry | | `zed.rainxch.githubstore.metainfo.xml` | AppStream metadata for Flathub listing | | `githubstore.sh` | Shell launcher (invokes `java -jar` with bundled JRE) | | `disable-android-for-flatpak.sh` | Strips Android targets for sandbox build | | `flatpak-sources.json` | Pre-downloaded Gradle dependencies (generated) | ================================================ FILE: packaging/flatpak/disable-android-for-flatpak.sh ================================================ #!/bin/bash # disable-android-for-flatpak.sh # # Strips all Android-related configuration from the project so it can # build inside the Flatpak sandbox where no Android SDK is available. # This script modifies files IN-PLACE — only run during Flatpak builds. set -euo pipefail echo "=== Disabling Android targets for Flatpak build ===" # ───────────────────────────────────────────────────────────────────── # 1. Root build.gradle.kts — comment out Android plugin declarations # ───────────────────────────────────────────────────────────────────── echo "[1/6] Patching root build.gradle.kts" sed -i \ -e 's|alias(libs.plugins.android.application)|// alias(libs.plugins.android.application)|' \ -e 's|alias(libs.plugins.android.library)|// alias(libs.plugins.android.library)|' \ -e 's|alias(libs.plugins.android.kotlin.multiplatform.library)|// alias(libs.plugins.android.kotlin.multiplatform.library)|' \ build.gradle.kts # ───────────────────────────────────────────────────────────────────── # 2. Convention plugins — replace Android plugin applies with no-ops # ───────────────────────────────────────────────────────────────────── CONVENTION_DIR="build-logic/convention/src/main/kotlin" echo "[2/6] Patching KmpLibraryConventionPlugin (remove Android library plugin + config)" cat > "$CONVENTION_DIR/KmpLibraryConventionPlugin.kt" << 'KOTLIN' import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.dependencies import zed.rainxch.githubstore.convention.configureJvmTarget import zed.rainxch.githubstore.convention.libs import zed.rainxch.githubstore.convention.pathToResourcePrefix import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension import org.gradle.kotlin.dsl.configure class KmpLibraryConventionPlugin : Plugin { override fun apply(target: Project) { with(target) { with(pluginManager) { apply("org.jetbrains.kotlin.multiplatform") apply("org.jetbrains.kotlin.plugin.serialization") } configureJvmTarget() extensions.configure { compilerOptions { freeCompilerArgs.add("-Xexpect-actual-classes") freeCompilerArgs.add("-Xmulti-dollar-interpolation") freeCompilerArgs.add("-opt-in=kotlin.RequiresOptIn") freeCompilerArgs.add("-opt-in=kotlin.time.ExperimentalTime") } } dependencies { "commonMainImplementation"(libs.findLibrary("kotlinx-serialization-json").get()) } } } } KOTLIN echo "[3/6] Patching CmpApplicationConventionPlugin (remove Android application)" cat > "$CONVENTION_DIR/CmpApplicationConventionPlugin.kt" << 'KOTLIN' import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.dependencies import zed.rainxch.githubstore.convention.configureJvmTarget import zed.rainxch.githubstore.convention.libs class CmpApplicationConventionPlugin : Plugin { override fun apply(target: Project) { with(target) { with(pluginManager) { apply("org.jetbrains.kotlin.multiplatform") apply("org.jetbrains.compose") apply("org.jetbrains.kotlin.plugin.compose") } configureJvmTarget() } } } KOTLIN echo "[4/6] Patching CmpLibraryConventionPlugin & CmpFeatureConventionPlugin" # CmpLibraryConventionPlugin — remove Android library dependency sed -i \ -e 's|apply("com.android.library")|// apply("com.android.library")|' \ "$CONVENTION_DIR/CmpLibraryConventionPlugin.kt" 2>/dev/null || true sed -i \ -e 's|apply("com.android.library")|// apply("com.android.library")|' \ "$CONVENTION_DIR/CmpFeatureConventionPlugin.kt" 2>/dev/null || true # Remove configureKotlinAndroid calls and Android extension blocks (only at call sites) for f in "$CONVENTION_DIR"/*.kt "$CONVENTION_DIR"/zed/rainxch/githubstore/convention/*.kt; do [ -f "$f" ] || continue # Skip files that define these functions (declarations, not call sites) grep -q "fun Project.configureAndroidTarget" "$f" && continue grep -q "fun Project.configureKotlinAndroid" "$f" && continue sed -i \ -e 's|configureAndroidTarget()|// configureAndroidTarget()|g' \ -e 's|configureKotlinAndroid(this)|// configureKotlinAndroid(this)|g' \ "$f" done # ───────────────────────────────────────────────────────────────────── # 3. KotlinMultiplatform.kt — skip Android configuration # ───────────────────────────────────────────────────────────────────── echo "[5/6] Patching KotlinMultiplatform.kt" cat > "$CONVENTION_DIR/zed/rainxch/githubstore/convention/KotlinMultiplatform.kt" << 'KOTLIN' package zed.rainxch.githubstore.convention import org.gradle.api.Project import org.gradle.kotlin.dsl.configure import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension internal fun Project.configureKotlinMultiplatform() { // Android target disabled for Flatpak build configureJvmTarget() extensions.configure { compilerOptions { freeCompilerArgs.add("-Xexpect-actual-classes") freeCompilerArgs.add("-Xmulti-dollar-interpolation") freeCompilerArgs.add("-opt-in=kotlin.RequiresOptIn") freeCompilerArgs.add("-opt-in=kotlin.time.ExperimentalTime") } } } KOTLIN # ───────────────────────────────────────────────────────────────────── # 4. Module build.gradle.kts files — remove android {} blocks # ───────────────────────────────────────────────────────────────────── echo "[6/6] Removing android {} blocks from module build.gradle.kts files" # composeApp — remove android {} block and its contents python3 -c " import re, sys with open('composeApp/build.gradle.kts', 'r') as f: content = f.read() # Remove top-level android { ... } blocks (handles nested braces) def remove_block(text, keyword): result = [] i = 0 while i < len(text): # Look for 'android {' at line start (possibly with whitespace) line_start = text.rfind('\n', 0, i) + 1 prefix = text[line_start:i].strip() if text[i:].startswith(keyword + ' {') or text[i:].startswith(keyword + '{'): if prefix == '' or prefix.endswith('\n'): # Find matching closing brace brace_start = text.index('{', i) depth = 1 j = brace_start + 1 while j < len(text) and depth > 0: if text[j] == '{': depth += 1 elif text[j] == '}': depth -= 1 j += 1 # Skip past the block and any trailing newline if j < len(text) and text[j] == '\n': j += 1 i = j continue result.append(text[i]) i += 1 return ''.join(result) content = remove_block(content, 'android') with open('composeApp/build.gradle.kts', 'w') as f: f.write(content) " # core/data — remove android {} block for gradle_file in \ core/data/build.gradle.kts \ core/domain/build.gradle.kts \ core/presentation/build.gradle.kts; do if [ -f "$gradle_file" ]; then python3 -c " import sys with open('$gradle_file', 'r') as f: lines = f.readlines() result = [] skip_depth = 0 i = 0 while i < len(lines): stripped = lines[i].strip() if stripped.startswith('android {') or stripped == 'android{': skip_depth = 1 i += 1 while i < len(lines) and skip_depth > 0: for ch in lines[i]: if ch == '{': skip_depth += 1 elif ch == '}': skip_depth -= 1 i += 1 continue result.append(lines[i]) i += 1 with open('$gradle_file', 'w') as f: f.writelines(result) " fi done # Remove AndroidApplicationComposeConventionPlugin registration attempt # (it won't compile without AGP) cat > "$CONVENTION_DIR/AndroidApplicationComposeConventionPlugin.kt" << 'KOTLIN' import org.gradle.api.Plugin import org.gradle.api.Project class AndroidApplicationComposeConventionPlugin : Plugin { override fun apply(target: Project) { // No-op: Android disabled for Flatpak build } } KOTLIN cat > "$CONVENTION_DIR/AndroidApplicationConventionPlugin.kt" << 'KOTLIN' import org.gradle.api.Plugin import org.gradle.api.Project class AndroidApplicationConventionPlugin : Plugin { override fun apply(target: Project) { // No-op: Android disabled for Flatpak build } } KOTLIN echo "=== Android targets disabled successfully ===" ================================================ FILE: packaging/flatpak/flatpak-sources.json ================================================ [ { "type": "file", "url": "https://dl.google.com/dl/android/maven2/androidx/databinding/databinding-common/8.13.2/databinding-common-8.13.2.jar", "sha512": "5d7539ba242138ae5ed2f0ddc9da6b0c3700bdf67c763196dc26e3557172f1a97284a3bb9592d3049bf96cab89602d0e65df95c7e3cb73f7c4632b166bae6ebd", "dest": "offline-repository/androidx/databinding/databinding-common/8.13.2", "dest-filename": "databinding-common-8.13.2.jar" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/androidx/databinding/databinding-common/8.13.2/databinding-common-8.13.2.pom", "sha512": "620d33b4a75059ecea751d5fa54c94122beff6bf00df1afea0a887a22346cc59f511e58275a16f5066741a11950404686ad1d093ecf22e3304899a8bab6b16cd", "dest": "offline-repository/androidx/databinding/databinding-common/8.13.2", "dest-filename": "databinding-common-8.13.2.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/androidx/databinding/databinding-compiler-common/8.13.2/databinding-compiler-common-8.13.2.jar", "sha512": "652d1833694c9311982fed2c5b41c6004c72a4051c75cedd2ec3acd2f968c7994d5fcf5e56d2d8013944948e95fa2f684c9a6cac4c4c79977a2d1c335365b729", "dest": "offline-repository/androidx/databinding/databinding-compiler-common/8.13.2", "dest-filename": "databinding-compiler-common-8.13.2.jar" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/androidx/databinding/databinding-compiler-common/8.13.2/databinding-compiler-common-8.13.2.pom", "sha512": "3c71b98612f8edb0dccc5074bc09b80d67b4eb4ba8ebde5e816cbbcdd0deee102c9d32e0921e74bffdc2106faeb1f4b0261acf56a82bc55ffd765b9cd39c9aab", "dest": "offline-repository/androidx/databinding/databinding-compiler-common/8.13.2", "dest-filename": "databinding-compiler-common-8.13.2.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/androidx/room/androidx.room.gradle.plugin/2.8.4/androidx.room.gradle.plugin-2.8.4.pom", "sha512": "948c7fbe9ecdb9f1f2a88a158411295be44ce60ffc64225c6842a0a08a938c50193e205dac0f60e8d66e0c4a940b37196c66cd4b2229870dbd215b56a27d85b4", "dest": "offline-repository/androidx/room/androidx.room.gradle.plugin/2.8.4", "dest-filename": "androidx.room.gradle.plugin-2.8.4.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/androidx/room/room-common/2.8.4/room-common-2.8.4.pom", "sha512": "50105d8200b9af90a687bb2c9f5e532925ac89b6be0541463c2c6116accf6dc265a310f408697833d0e5020d6402b363fc224e6a571f551416d1cd4aa8ea12c8", "dest": "offline-repository/androidx/room/room-common/2.8.4", "dest-filename": "room-common-2.8.4.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/androidx/room/room-compiler-processing-testing/2.8.4/room-compiler-processing-testing-2.8.4.pom", "sha512": "388f70063e366cf25ef58d7401947ac7f1c6801e29f9b9289dbec696b11177ee239cec49bc8c9f6733145e96856669e6c42655888b1ddd48bead60a870af63e7", "dest": "offline-repository/androidx/room/room-compiler-processing-testing/2.8.4", "dest-filename": "room-compiler-processing-testing-2.8.4.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/androidx/room/room-compiler-processing/2.8.4/room-compiler-processing-2.8.4.pom", "sha512": "9e88ff50c938724b5f29b18c1914e5b9a83d29b8e96c03beea2daa86a4b81aa665f30ffbe8d79bef7db9a65922c84d4b76c38fc99fad8ed38d13b8306e21dc30", "dest": "offline-repository/androidx/room/room-compiler-processing/2.8.4", "dest-filename": "room-compiler-processing-2.8.4.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/androidx/room/room-compiler/2.8.4/room-compiler-2.8.4.pom", "sha512": "5f856c59574a0519cc65eec1053a08e85aac6f08fa3dbfad51ca2c697d47e005681e03c51f3643f60b31c3ee77802c787bae404f552ee9ef0ae811441aeb40e7", "dest": "offline-repository/androidx/room/room-compiler/2.8.4", "dest-filename": "room-compiler-2.8.4.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/androidx/room/room-external-antlr/2.8.4/room-external-antlr-2.8.4.pom", "sha512": "cd524e47bfcfc66c1a6f18e28d67e84b643102f950d8b9c5903b5c0d71b9a13359e8bf71412bc9b86d78e576421ec8714631e108249be7f45b5324ac1ff579e8", "dest": "offline-repository/androidx/room/room-external-antlr/2.8.4", "dest-filename": "room-external-antlr-2.8.4.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/androidx/room/room-gradle-plugin/2.8.4/room-gradle-plugin-2.8.4.jar", "sha512": "44d219f383e862c2f052a7f076d41a3ca6fd87274d6866dc5ae59cbb805cc43b048a978723d263ef7bd48b55f72235d96e3a19ebd52b58bb800c2e8e751df43c", "dest": "offline-repository/androidx/room/room-gradle-plugin/2.8.4", "dest-filename": "room-gradle-plugin-2.8.4.jar" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/androidx/room/room-gradle-plugin/2.8.4/room-gradle-plugin-2.8.4.module", "sha512": "c913ef5e3a35cfe1a1d7324bf43c0f1afba416f1743968f745a5d2115752df7abe0a886de50541b44de8b6447f07b5ab460a8198a3872a237ad20e9a5821fd07", "dest": "offline-repository/androidx/room/room-gradle-plugin/2.8.4", "dest-filename": "room-gradle-plugin-2.8.4.module" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/androidx/room/room-gradle-plugin/2.8.4/room-gradle-plugin-2.8.4.pom", "sha512": "793b35974dbfeeb8bc452acfeba4c13d9c84ce2155f23e0cffd4cc7cd724d179c7a2728aeeacc6d6016f94a408a4e074022bef96ae2c0688272e1b2df83e8773", "dest": "offline-repository/androidx/room/room-gradle-plugin/2.8.4", "dest-filename": "room-gradle-plugin-2.8.4.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/androidx/room/room-guava/2.8.4/room-guava-2.8.4.pom", "sha512": "6f148fd3ed526c4e1a4651ef31349db9279ddcf80162b6a26c1f3cb5b23604f63a7f2e547f51752f8e503764b5458cd7b2e7a658035475a84c24d1578407c4d9", "dest": "offline-repository/androidx/room/room-guava/2.8.4", "dest-filename": "room-guava-2.8.4.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/androidx/room/room-ktx/2.8.4/room-ktx-2.8.4.pom", "sha512": "1952df8cd6f341d980b2dbb8cfad9db7e303fcc153f11a9c998382944b9ff9430f0c19a55b3a4c9cf8d119242eede0b99f0d6481506541e0bc708e58b8a203ba", "dest": "offline-repository/androidx/room/room-ktx/2.8.4", "dest-filename": "room-ktx-2.8.4.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/androidx/room/room-migration/2.8.4/room-migration-2.8.4.pom", "sha512": "f5a5d6f387ca02767742ccc1b77a7677b3d9ab159fd56a71250df9fafafb7aa98bb3dab8f4af0b8648b983f7e54bce9e1966184e91be80af0ec47fe3f268982e", "dest": "offline-repository/androidx/room/room-migration/2.8.4", "dest-filename": "room-migration-2.8.4.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/androidx/room/room-paging-guava/2.8.4/room-paging-guava-2.8.4.pom", "sha512": "b7456558c9e275de0c99d4c08cf07834bcca3df4e89d245d1b8e9b3dca4a245fa0e6d3c5e88e4a484e5d817fd07abda2641e9b47871e22a6c52953c42624ac1b", "dest": "offline-repository/androidx/room/room-paging-guava/2.8.4", "dest-filename": "room-paging-guava-2.8.4.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/androidx/room/room-paging-rxjava2/2.8.4/room-paging-rxjava2-2.8.4.pom", "sha512": "2f2e0ab85e10d99b83c14e4cdd83e750cb86927fb59566d3b63795d0ee24e7b54d3fe57772e3394f9f61403622f421faec22fe0ac5ad4271d177024a16aad093", "dest": "offline-repository/androidx/room/room-paging-rxjava2/2.8.4", "dest-filename": "room-paging-rxjava2-2.8.4.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/androidx/room/room-paging-rxjava3/2.8.4/room-paging-rxjava3-2.8.4.pom", "sha512": "985d79c62eee22c7f3698d570d9d431fef054e0b8260026fde6e66c2f3216c83e37182e2f66b7e994324a1917931e6727aca331688e49790513e725256cef89a", "dest": "offline-repository/androidx/room/room-paging-rxjava3/2.8.4", "dest-filename": "room-paging-rxjava3-2.8.4.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/androidx/room/room-paging/2.8.4/room-paging-2.8.4.pom", "sha512": "b317fc765b34d37502cbdf6aebab75502d9fac73b904773cea5762afbc69772c4eeef38b4b3f7386ea92f403b3cedff000c69ab74dcc3c85d5179a3e31ea6321", "dest": "offline-repository/androidx/room/room-paging/2.8.4", "dest-filename": "room-paging-2.8.4.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/androidx/room/room-runtime/2.8.4/room-runtime-2.8.4.pom", "sha512": "550b98396d2d83da40ee9147fe6fd41bbfe06a1c115fbda7a794a8c2c3434b527016e8cd83ecc80eddc5bbc1444c20e584200bd9b19a7a430c440674adf42c79", "dest": "offline-repository/androidx/room/room-runtime/2.8.4", "dest-filename": "room-runtime-2.8.4.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/androidx/room/room-rxjava2/2.8.4/room-rxjava2-2.8.4.pom", "sha512": "57f771cf8611699327ae3a680adf3dbf0c7cf03e3288a04e2e1a969b7ad8e443ef020d52c7e6fd125d98311bd93bf6fbaa7856a2175698a599fc748a73b8b186", "dest": "offline-repository/androidx/room/room-rxjava2/2.8.4", "dest-filename": "room-rxjava2-2.8.4.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/androidx/room/room-rxjava3/2.8.4/room-rxjava3-2.8.4.pom", "sha512": "e787ceb1edd6f4c520dd424de7b5455da0a9b57b53b24fc77647eabf9cbeda03506c21af12940bfe99a9899bc30e35fd5a4fc0cc5b50a235c5330162ea4a304c", "dest": "offline-repository/androidx/room/room-rxjava3/2.8.4", "dest-filename": "room-rxjava3-2.8.4.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/androidx/room/room-sqlite-wrapper/2.8.4/room-sqlite-wrapper-2.8.4.pom", "sha512": "66e9bd58cdd0cb2fb6fe7ec6c67052e8090396ea821f2c264a46ca2e925d15ed0aefb7210fd4a959870ce8e466608350d67b1dea583726f3f65f97529adbbcf4", "dest": "offline-repository/androidx/room/room-sqlite-wrapper/2.8.4", "dest-filename": "room-sqlite-wrapper-2.8.4.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/androidx/room/room-testing/2.8.4/room-testing-2.8.4.pom", "sha512": "8821f2fb82de6ff883ded9c6f5620d31dbced1b65f8d2e91d689e1effe615fa67564ee4c438641f792d38397418ec2f391f8d33bf64ebdc2c9cbd85749995eef", "dest": "offline-repository/androidx/room/room-testing/2.8.4", "dest-filename": "room-testing-2.8.4.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/application/com.android.application.gradle.plugin/8.13.2/com.android.application.gradle.plugin-8.13.2.pom", "sha512": "ac7ab72b01a49144fc7b34e2f60a144120b4583bac09c087ff54397e08620ee76c2e34daf00ef3b67acc5d34b136f8f63eb51cd85d89083ab01731d01827ea0e", "dest": "offline-repository/com/android/application/com.android.application.gradle.plugin/8.13.2", "dest-filename": "com.android.application.gradle.plugin-8.13.2.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/databinding/baseLibrary/8.13.2/baseLibrary-8.13.2.jar", "sha512": "c1f350e35e37c065c887b52a4720186f96dfe87436791a61ea6424c094edd0d41435925f26b8433388608e197fe1c82a9596be0d366f28f3d3373f2c8bd2693a", "dest": "offline-repository/com/android/databinding/baseLibrary/8.13.2", "dest-filename": "baseLibrary-8.13.2.jar" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/databinding/baseLibrary/8.13.2/baseLibrary-8.13.2.pom", "sha512": "8e5e92b2cf1f89dcd0f68319308323b9b71f517f1967faa1d255b32bae9525d270d5fdfa1f201b40abbcb0c8a5be65c1484007c24a447d5e076826bb32f93aa4", "dest": "offline-repository/com/android/databinding/baseLibrary/8.13.2", "dest-filename": "baseLibrary-8.13.2.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/kotlin/multiplatform/library/com.android.kotlin.multiplatform.library.gradle.plugin/8.13.2/com.android.kotlin.multiplatform.library.gradle.plugin-8.13.2.pom", "sha512": "dc740260d9574c2786ce359d1c3ec0ed98817496c667535f9050b1c884d163951db08736e67124da6be62d508af6261a4d654e91ac9c79941d8e393692112a91", "dest": "offline-repository/com/android/kotlin/multiplatform/library/com.android.kotlin.multiplatform.library.gradle.plugin/8.13.2", "dest-filename": "com.android.kotlin.multiplatform.library.gradle.plugin-8.13.2.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/library/com.android.library.gradle.plugin/8.13.2/com.android.library.gradle.plugin-8.13.2.pom", "sha512": "159dff919662124edc68968f340daaf51aadbd747c2569df4560d513e4c12e02d30a13880722d742ad2597bdcfa8239336f08d059c6b4f981a80e7b8f3d7cfe6", "dest": "offline-repository/com/android/library/com.android.library.gradle.plugin/8.13.2", "dest-filename": "com.android.library.gradle.plugin-8.13.2.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/signflinger/8.13.2/signflinger-8.13.2.jar", "sha512": "17c42a17b67872ee819f94214d144ad08ce01ca66c323a6cecff46fb126177ab644a727551b90afa8df52f0eea2cbb8570d4be310ba1080199cce81ccb36926f", "dest": "offline-repository/com/android/signflinger/8.13.2", "dest-filename": "signflinger-8.13.2.jar" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/signflinger/8.13.2/signflinger-8.13.2.pom", "sha512": "dc05b9c6430f73618c5b2d2ca8da9cd939b6b642558debce58156d49a11353d344ade6aaa840aed15774abe4cc11632a3ec1334f7057bb261f612eba13ea4626", "dest": "offline-repository/com/android/signflinger/8.13.2", "dest-filename": "signflinger-8.13.2.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/analytics-library/crash/31.13.2/crash-31.13.2.jar", "sha512": "76bc17551f2225c42ca8b9277032f651b2f4835359a144569e154aeb0bc9b401259f9b7a5f4cb056a9a82b95e6313bdb5b8c3113259a36784b3913c17ddfd4a9", "dest": "offline-repository/com/android/tools/analytics-library/crash/31.13.2", "dest-filename": "crash-31.13.2.jar" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/analytics-library/crash/31.13.2/crash-31.13.2.pom", "sha512": "782f7e4f8ea9bc00d6a373802960a0e0f92e1827995246d4b9855ef973e770696d39ce78e85e23bf99db9431361e11cf681d1b4e967ed5d093acb8bddbfd3125", "dest": "offline-repository/com/android/tools/analytics-library/crash/31.13.2", "dest-filename": "crash-31.13.2.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/analytics-library/protos/31.13.2/protos-31.13.2.jar", "sha512": "6c336bbb3125d75f6db312c74c92f2002b8b11561a9710f0454f11ba4382e51f7456fd06319f5272ec12e89615d9695589b45bf292d746463e62b0d2b1543bb0", "dest": "offline-repository/com/android/tools/analytics-library/protos/31.13.2", "dest-filename": "protos-31.13.2.jar" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/analytics-library/protos/31.13.2/protos-31.13.2.pom", "sha512": "262d1288afc58d833011d9ebb2e016e7a121d03680d5fef5fbd27f07ffc02cbe5156c8a3c04a555810186c885be7290cd4d457861c6866297b8012fcc46859da", "dest": "offline-repository/com/android/tools/analytics-library/protos/31.13.2", "dest-filename": "protos-31.13.2.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/analytics-library/shared/31.13.2/shared-31.13.2.jar", "sha512": "2d8c7250fdd64fa84994dd4a1b7a40b96eb2cd74fdb45007b092da9c2ed7f6d576a22aa772d5daac6577652767ee5c4708072f3975609c06413998ae1a1a664d", "dest": "offline-repository/com/android/tools/analytics-library/shared/31.13.2", "dest-filename": "shared-31.13.2.jar" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/analytics-library/shared/31.13.2/shared-31.13.2.pom", "sha512": "95bb6af4ffe2b76321c1b1100678dd888d79fa40ebdbfc189c12c7b40776254bf01ab37f9e99e28e2521cd0255c5bc92c3fc85508fe4fc3a621472f1ab4fcc74", "dest": "offline-repository/com/android/tools/analytics-library/shared/31.13.2", "dest-filename": "shared-31.13.2.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/analytics-library/tracker/31.13.2/tracker-31.13.2.jar", "sha512": "6b59487d16cccdde6950425352f84198f7cc079ac639810f591a9bc0149fbd4199613b5c868c3aa3c9710e448ecdd395b4a8e9bc625511b45e7f9147555cce4f", "dest": "offline-repository/com/android/tools/analytics-library/tracker/31.13.2", "dest-filename": "tracker-31.13.2.jar" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/analytics-library/tracker/31.13.2/tracker-31.13.2.pom", "sha512": "c3c223af6e18908a5c4f6c00c52ed314f5c4f4706b0924e8fd3eb7afdf04d4c5e3a34769d3951aab4d6dcec980337fe344273cf20b725280cc21ff5307a7cf77", "dest": "offline-repository/com/android/tools/analytics-library/tracker/31.13.2", "dest-filename": "tracker-31.13.2.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/annotations/31.13.2/annotations-31.13.2.jar", "sha512": "a6ea020912ec68b3bfc20c3ca5df0627d5a3cf6cae544e7d8a25a7b71e7fbd6af722883ef3085a25d9669de47373a27fb281a4e677725ced788dabbfea6c8c77", "dest": "offline-repository/com/android/tools/annotations/31.13.2", "dest-filename": "annotations-31.13.2.jar" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/annotations/31.13.2/annotations-31.13.2.pom", "sha512": "7a13132257b182c844d59342610fa3fe0f5bc666af27ef61e4be32de1409eeaf0c68886e65cc8ba99986ff0e1963242339ecbdc9a5c9eecc80f5f36e89a7d1d3", "dest": "offline-repository/com/android/tools/annotations/31.13.2", "dest-filename": "annotations-31.13.2.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/build/aapt2-proto/8.13.2-14304508/aapt2-proto-8.13.2-14304508.jar", "sha512": "015ad458966f24865fccdb016ae09f1ec83887c225e2a4063bd01cd6c7fcaf8f5a583d080547d9d806f2f4cfadea45304fd72dca1e30da7d75d6659214909950", "dest": "offline-repository/com/android/tools/build/aapt2-proto/8.13.2-14304508", "dest-filename": "aapt2-proto-8.13.2-14304508.jar" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/build/aapt2-proto/8.13.2-14304508/aapt2-proto-8.13.2-14304508.module", "sha512": "94a4d5d6278f5e2193d7dab17de46bc3f2289e7c249871ef114d6f2664970da0f8ab6b9415d6ad0847a17d87f8cd064fe2b5d5a43d66accb3f321a799b20a7e6", "dest": "offline-repository/com/android/tools/build/aapt2-proto/8.13.2-14304508", "dest-filename": "aapt2-proto-8.13.2-14304508.module" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/build/aapt2-proto/8.13.2-14304508/aapt2-proto-8.13.2-14304508.pom", "sha512": "bc44d1170b5b3cd0f75876c546021da5e290a90c1eb4ce26255ad38f09db129c3eedd9f1d2d32819e4c3c8d87bca0f9be1890dbbfad740ca45fee73c6167612a", "dest": "offline-repository/com/android/tools/build/aapt2-proto/8.13.2-14304508", "dest-filename": "aapt2-proto-8.13.2-14304508.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/build/aaptcompiler/8.13.2/aaptcompiler-8.13.2.jar", "sha512": "3167404cacdb8aa011cb2e8f6d326095c5bb811593169942b1afe27d84d66d00618cb403ac437538fee79b00d4762782ddd8aa955781bbd4462bf2d150d13b34", "dest": "offline-repository/com/android/tools/build/aaptcompiler/8.13.2", "dest-filename": "aaptcompiler-8.13.2.jar" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/build/aaptcompiler/8.13.2/aaptcompiler-8.13.2.module", "sha512": "f73f723c9c397aa5e1776351050000100d3003954ef34e809db7be2acfe158bc534efd507108ee88a6225de19dbb96d1b06ade17fd17bb497d8076d1db0f53db", "dest": "offline-repository/com/android/tools/build/aaptcompiler/8.13.2", "dest-filename": "aaptcompiler-8.13.2.module" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/build/aaptcompiler/8.13.2/aaptcompiler-8.13.2.pom", "sha512": "16bf8ab4d06540a50d0bde15eb71e8198475b40f80881bae12ae62ecefb285c64fcae78b10d6e9dd0b931a9d1dc639f76144f7bcac102d318aa2a92d7d5b2473", "dest": "offline-repository/com/android/tools/build/aaptcompiler/8.13.2", "dest-filename": "aaptcompiler-8.13.2.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/build/apksig/8.13.2/apksig-8.13.2.jar", "sha512": "a503e89577ad60e6109731e46b3d00fe4d04a3dd4971a88f1693f568f9bcec7a65c809b12dd750da1b68671e5fab45436538c1abfea37de84c7dc2f917acacd8", "dest": "offline-repository/com/android/tools/build/apksig/8.13.2", "dest-filename": "apksig-8.13.2.jar" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/build/apksig/8.13.2/apksig-8.13.2.pom", "sha512": "ee1f826990638478b92abfc70380b772580756efeb95bf587821d3e5fab1e2d786437ba1988a19905abf58ed12872fb269ca2420828baf916e3d997ee84a6253", "dest": "offline-repository/com/android/tools/build/apksig/8.13.2", "dest-filename": "apksig-8.13.2.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/build/apkzlib/8.13.2/apkzlib-8.13.2.jar", "sha512": "6b728def0cdafe1be7c17c753cd279c864bfb01f48529a435028c66c054f7c56c330ad728b60c7a2ba6b054c8e81588904d39c80e62f301a1f6c754504c636d3", "dest": "offline-repository/com/android/tools/build/apkzlib/8.13.2", "dest-filename": "apkzlib-8.13.2.jar" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/build/apkzlib/8.13.2/apkzlib-8.13.2.pom", "sha512": "fe0d26662254eff21f551be4ed7ec5375014d0baf3a1d3d1e59d089def09081710c41be9632a50e4f2b6dbde7e86dfdb3d8e465771a4d770b5172f8608a8a715", "dest": "offline-repository/com/android/tools/build/apkzlib/8.13.2", "dest-filename": "apkzlib-8.13.2.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/build/builder-model/8.13.2/builder-model-8.13.2.jar", "sha512": "b386d3b5cd589c740d2b48d9d1269648cf03c8e5e8485060941ecdce48e2a91449a0d8a3d68a66720a7031c9756d16a3c313e4fd665d2d657879ce570b4cc7e9", "dest": "offline-repository/com/android/tools/build/builder-model/8.13.2", "dest-filename": "builder-model-8.13.2.jar" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/build/builder-model/8.13.2/builder-model-8.13.2.module", "sha512": "908a53d291884465fb153e80e2bdc70955eea58b2cee8a08a519205a2a845419e6617a12873e0c795eff41c0468712771cc410b44b7b7b12c8d134010fba5ec2", "dest": "offline-repository/com/android/tools/build/builder-model/8.13.2", "dest-filename": "builder-model-8.13.2.module" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/build/builder-model/8.13.2/builder-model-8.13.2.pom", "sha512": "00524faa35b2bbaac535e8f19e2e1cd74312a541b0dd45bb48b8db47321612416fc4986cb27fed2abf77faa9c307d00a3849582e580c3d45ff366d9426ddb10d", "dest": "offline-repository/com/android/tools/build/builder-model/8.13.2", "dest-filename": "builder-model-8.13.2.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/build/builder-test-api/8.13.2/builder-test-api-8.13.2.jar", "sha512": "25acd976393f36fecd3a689f043a8b5b85b4924a74866d437b4b87b86a4c684c4bb3d9d23167fc7abbeba4a7c57e34463459fb830b5dce874a4f95422dd24fee", "dest": "offline-repository/com/android/tools/build/builder-test-api/8.13.2", "dest-filename": "builder-test-api-8.13.2.jar" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/build/builder-test-api/8.13.2/builder-test-api-8.13.2.module", "sha512": "2809f9b7c30edc33ccfcd8b55b1478cc1aec67858c044ecdee9c6a4792908ed7b723df1261700f9520012e5d72474bf700c98b1106386492389294c529ddfe46", "dest": "offline-repository/com/android/tools/build/builder-test-api/8.13.2", "dest-filename": "builder-test-api-8.13.2.module" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/build/builder-test-api/8.13.2/builder-test-api-8.13.2.pom", "sha512": "47d00f07218086c04acbd2d7718e01ac5f870c9653179c060a669028c6eedcff5c7fee7d3aff69d081be1d9f0dc6a827c7962b3722bb72df04b1aa88d059d0b3", "dest": "offline-repository/com/android/tools/build/builder-test-api/8.13.2", "dest-filename": "builder-test-api-8.13.2.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/build/builder/8.13.2/builder-8.13.2.jar", "sha512": "89b08d1750d73cf45f1e6f7f2030b2b702eff7a3da88f3a9a9550a3641e280c5a3f1d506020d10fbcb6258063aeb858eeee955d68086f4d386d248a635dcbdda", "dest": "offline-repository/com/android/tools/build/builder/8.13.2", "dest-filename": "builder-8.13.2.jar" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/build/builder/8.13.2/builder-8.13.2.module", "sha512": "80240214f0f02df3f72971fb4f76cd5e620b55638b58291c02eb487f774ea5a726540b62fc9657a737ac61b363b96e26ed52d274ef2085f99fd615664872cf4c", "dest": "offline-repository/com/android/tools/build/builder/8.13.2", "dest-filename": "builder-8.13.2.module" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/build/builder/8.13.2/builder-8.13.2.pom", "sha512": "0cff48e01c5cb39053935310b2c0d8a24bd1f20cba81b97abdd23530dae67455658b5d602ae2e1b8c67dd938072502eab8b56be69c35d0b436172cef8a1458a8", "dest": "offline-repository/com/android/tools/build/builder/8.13.2", "dest-filename": "builder-8.13.2.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/build/bundletool/1.18.1/bundletool-1.18.1.jar", "sha512": "8d05cabd33fc433a40cc275d32269c188b13cfe251bc6ccc98d3937903c94f4d1209f2b684fde6548730ff999e069cd3a07969aa2f791a3653c5546f36b5f746", "dest": "offline-repository/com/android/tools/build/bundletool/1.18.1", "dest-filename": "bundletool-1.18.1.jar" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/build/bundletool/1.18.1/bundletool-1.18.1.pom", "sha512": "7d40fc5b3be06b8bc4a8db2f778ab22c20c5f433ac8a53d5fef4d14ad3a8cfb97e09e8d9391778b20dfdee7653ca3e6d9dd27508ab1475819c316dc1f28d5f9e", "dest": "offline-repository/com/android/tools/build/bundletool/1.18.1", "dest-filename": "bundletool-1.18.1.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/build/gradle-api/8.13.2/gradle-api-8.13.2.jar", "sha512": "f77040e938668ef7a345d6eae6ff32248fa5ea994c6eb2f8c5c7f4eec3120fb589e29d9ef4b7c8b5469fc609c8614ce9af8ee8a1b7d1a6e236575c6d1cb7ad30", "dest": "offline-repository/com/android/tools/build/gradle-api/8.13.2", "dest-filename": "gradle-api-8.13.2.jar" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/build/gradle-api/8.13.2/gradle-api-8.13.2.module", "sha512": "835ed000e80241b81bf169ccd66d55759ae06aa155134a47a2505d722b67887e4b86c9331208e2a648dd211c129596033b3542648a12fba768a63176d08b7d1d", "dest": "offline-repository/com/android/tools/build/gradle-api/8.13.2", "dest-filename": "gradle-api-8.13.2.module" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/build/gradle-api/8.13.2/gradle-api-8.13.2.pom", "sha512": "cab29894d2ddaef258d4e352b07b8e9560f5215e220be5269b8d3ad61253dce3a8183eeccea681f8b37dd9f0d9dc24e86affbe4acc7b0ddbd8d9ef67f4aab24e", "dest": "offline-repository/com/android/tools/build/gradle-api/8.13.2", "dest-filename": "gradle-api-8.13.2.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/build/gradle-common-api/8.13.2/gradle-common-api-8.13.2.jar", "sha512": "ce61cb68f9f9fc41752f78c0786b9bb3ac1adf4d838ec053a51cc7e3e9bbc7a517b3c091e1a86a4fe7259e2bbe6ca753231373d047d4f77dc0561a412045fa39", "dest": "offline-repository/com/android/tools/build/gradle-common-api/8.13.2", "dest-filename": "gradle-common-api-8.13.2.jar" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/build/gradle-common-api/8.13.2/gradle-common-api-8.13.2.module", "sha512": "1d041eed1b285fb767413324bfdff6b1daee758f0880a69ab26f3808523bb91d95c8345f5aad665bef4771dfac588b14704929b4bb7ddf1ffb0783fb69170c7a", "dest": "offline-repository/com/android/tools/build/gradle-common-api/8.13.2", "dest-filename": "gradle-common-api-8.13.2.module" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/build/gradle-common-api/8.13.2/gradle-common-api-8.13.2.pom", "sha512": "4cb11d9bf5cce75b6d4f21f5301d96d03cc5ccdcb4851f5e6b63a4cf3d26cbef2e859ef38dead51654a800d55d47a50c3e1a8ae8e2ab6c5ca55d20e48611648e", "dest": "offline-repository/com/android/tools/build/gradle-common-api/8.13.2", "dest-filename": "gradle-common-api-8.13.2.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/build/gradle-settings-api/8.13.2/gradle-settings-api-8.13.2.jar", "sha512": "aca15ccad0416a52345fc66ff0a766c650786da87b0461cf446fe866ab74eba3c793b0b376163f1a543ec122aea4d64a5f2c7e3ec1b2a36cbf4769f1f488de2b", "dest": "offline-repository/com/android/tools/build/gradle-settings-api/8.13.2", "dest-filename": "gradle-settings-api-8.13.2.jar" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/build/gradle-settings-api/8.13.2/gradle-settings-api-8.13.2.module", "sha512": "580b86742978482e4f8678dddbdf7f8b8c7b33d5b731edbe68fca56e9ef2adb21875bc32c9b004c655b80b29196f25f1a3d39ca8f0720a121fc59d9d5bc6ee13", "dest": "offline-repository/com/android/tools/build/gradle-settings-api/8.13.2", "dest-filename": "gradle-settings-api-8.13.2.module" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/build/gradle-settings-api/8.13.2/gradle-settings-api-8.13.2.pom", "sha512": "f6954fac8f5ad38c82122e50dea3867efccf95fee238dcde7e0e14338fed50ab357196ff9c8f8db8a656b45f5ada5713f5d50322d08ffbe84245f42e639c6deb", "dest": "offline-repository/com/android/tools/build/gradle-settings-api/8.13.2", "dest-filename": "gradle-settings-api-8.13.2.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/build/gradle/8.13.2/gradle-8.13.2.jar", "sha512": "11d0905bb50514dc9800c05da7ea80ddefe473ff46aeb8dcfb797e548e8b8c7b5609ff8fc879346b3ab4a37c93f39ccabc7601c5f4775c7abb8326cb8062f182", "dest": "offline-repository/com/android/tools/build/gradle/8.13.2", "dest-filename": "gradle-8.13.2.jar" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/build/gradle/8.13.2/gradle-8.13.2.module", "sha512": "a48c63cf8fdaa37eae2e2a0ab601c34bce2a91783c2266c7a96c1ce4044626c877bfaa7a2543ab4303e22f939856df060451bc934c5c8245f2ba5762399c5c84", "dest": "offline-repository/com/android/tools/build/gradle/8.13.2", "dest-filename": "gradle-8.13.2.module" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/build/gradle/8.13.2/gradle-8.13.2.pom", "sha512": "ee02d56bbb4bf65003c74a510fcfee13c8c45667ba7213847b0666e70995c43c3111bedb03ef7fc0d1c7c87b13491ba1804ea00c68b5e30d0af773d64379c6d0", "dest": "offline-repository/com/android/tools/build/gradle/8.13.2", "dest-filename": "gradle-8.13.2.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/build/jetifier/jetifier-core/1.0.0-beta10/jetifier-core-1.0.0-beta10.jar", "sha512": "7c87414a0753a7bc5e14c34acb9bec7911960ce7832e8b78825c0fa516631b4687b839b50ca938b75fbe439636b9a2823303b68cc52d36e9559ae750caa978fc", "dest": "offline-repository/com/android/tools/build/jetifier/jetifier-core/1.0.0-beta10", "dest-filename": "jetifier-core-1.0.0-beta10.jar" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/build/jetifier/jetifier-core/1.0.0-beta10/jetifier-core-1.0.0-beta10.module", "sha512": "30592727ce41feb2cf3eef2cc59e06a9cf838cd65f724fc65d7dc249c3a89f16e6a21ef68a7751739bbbf7656a7ddaadac37395583d7fa3f574eb1ec57ec5bdc", "dest": "offline-repository/com/android/tools/build/jetifier/jetifier-core/1.0.0-beta10", "dest-filename": "jetifier-core-1.0.0-beta10.module" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/build/jetifier/jetifier-core/1.0.0-beta10/jetifier-core-1.0.0-beta10.pom", "sha512": "1dacabd19fe8e2b5be6bd1028f7b10c2100873a599f197c2e63601ac23774e192e0612bc3eede9754e5c38d3a7439f4be781670314af070daa4c0eaba51c58ef", "dest": "offline-repository/com/android/tools/build/jetifier/jetifier-core/1.0.0-beta10", "dest-filename": "jetifier-core-1.0.0-beta10.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/build/jetifier/jetifier-processor/1.0.0-beta10/jetifier-processor-1.0.0-beta10.jar", "sha512": "5f7316f3fbb67ef1c8993be3343a8ae0e6e1363053c306beb43a4863185b8b614ea76f1b36e4d1acfbe4e8643f48af214ea2f988d7f53b9af088dbaed6a4987c", "dest": "offline-repository/com/android/tools/build/jetifier/jetifier-processor/1.0.0-beta10", "dest-filename": "jetifier-processor-1.0.0-beta10.jar" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/build/jetifier/jetifier-processor/1.0.0-beta10/jetifier-processor-1.0.0-beta10.module", "sha512": "6485aeafd7b53471c4a5424112eb9031fc890d365a6813489b3c671c4d0b52ddfbaab07f2e522a11f1e2112f011bac422086a70bca639139197c2e360e078fbc", "dest": "offline-repository/com/android/tools/build/jetifier/jetifier-processor/1.0.0-beta10", "dest-filename": "jetifier-processor-1.0.0-beta10.module" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/build/jetifier/jetifier-processor/1.0.0-beta10/jetifier-processor-1.0.0-beta10.pom", "sha512": "7969cb4ccc5c767cb17af91911d6169a43017b2f352ee4bf02de25abcd882a4617386916d2218dae52769e02a38704616ace9f1f5b34fa5ff6f48142f9fa00ea", "dest": "offline-repository/com/android/tools/build/jetifier/jetifier-processor/1.0.0-beta10", "dest-filename": "jetifier-processor-1.0.0-beta10.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/build/manifest-merger/31.13.2/manifest-merger-31.13.2.jar", "sha512": "612cf2b0830a435d4e2d35b3d4d1ffbb89b3e161f840222f949a464a8a83d8ac619d2a8ce5fb18f56d8364d923a6522cfe0c078b7f83aad28f2b9bc2912365fc", "dest": "offline-repository/com/android/tools/build/manifest-merger/31.13.2", "dest-filename": "manifest-merger-31.13.2.jar" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/build/manifest-merger/31.13.2/manifest-merger-31.13.2.module", "sha512": "357fb7573ec3574c05e2d631e086832f837ca8812967a06006defbfc31cbf1d673f395c0e695ca39fe1ffcbfe5bb9dbe6575e12d66d72aecc0a87bf35ccac0d1", "dest": "offline-repository/com/android/tools/build/manifest-merger/31.13.2", "dest-filename": "manifest-merger-31.13.2.module" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/build/manifest-merger/31.13.2/manifest-merger-31.13.2.pom", "sha512": "1919d0ccde2285c31e3fef58edc07b447a68c53ab12ea5a287f1e8f78a3ba682d0446584b649c01b5cf77990f6494d08f64e4a704c248a977a8a8b0231ab1fab", "dest": "offline-repository/com/android/tools/build/manifest-merger/31.13.2", "dest-filename": "manifest-merger-31.13.2.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/build/transform-api/2.0.0-deprecated-use-gradle-api/transform-api-2.0.0-deprecated-use-gradle-api.jar", "sha512": "f7af126566e550c43586db59bb020a2f85387d4e60b533b2a04f988e75350584cc557e49ea3f1237327e487e2a50de98196514eb218c6ce83661d2291d2cde54", "dest": "offline-repository/com/android/tools/build/transform-api/2.0.0-deprecated-use-gradle-api", "dest-filename": "transform-api-2.0.0-deprecated-use-gradle-api.jar" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/build/transform-api/2.0.0-deprecated-use-gradle-api/transform-api-2.0.0-deprecated-use-gradle-api.pom", "sha512": "d0b9a08d477c7e59ed723868ec676b24ba6043038c4f0b4a31368fb3a55e39b69e08f666142f10b2a55aec8f64d9435be0ea9fe039a5fb0a09ca88e4085a21a6", "dest": "offline-repository/com/android/tools/build/transform-api/2.0.0-deprecated-use-gradle-api", "dest-filename": "transform-api-2.0.0-deprecated-use-gradle-api.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/common/31.13.2/common-31.13.2.jar", "sha512": "23af8b9e44d83cde8a5e662ceab722baec1b03264d96f96324cbc983ce525280ba9dc021d745f46f657aa11867943199cbf186b5d1992e902177c5a4c6635781", "dest": "offline-repository/com/android/tools/common/31.13.2", "dest-filename": "common-31.13.2.jar" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/common/31.13.2/common-31.13.2.pom", "sha512": "c5051378f28e42c99eb6597b7e23aee6f1e6aaafd4953cc0416616230a28edc5fb88b40fa410edf89c7210ec48fe7f9af5d408c86f3aea0cbe47168bf535bb59", "dest": "offline-repository/com/android/tools/common/31.13.2", "dest-filename": "common-31.13.2.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/ddms/ddmlib/31.13.2/ddmlib-31.13.2.jar", "sha512": "7555895ae6ef80361efbbb46f322f9e9f14eefffebef2383f2621882d7e9dd977c72f390cf6b53dbb985d841c8996aecedf7598c741f8692addeb5ede5089e31", "dest": "offline-repository/com/android/tools/ddms/ddmlib/31.13.2", "dest-filename": "ddmlib-31.13.2.jar" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/ddms/ddmlib/31.13.2/ddmlib-31.13.2.pom", "sha512": "1d9dc37f752a76e9329de81b57310966a4d5e71ecfb065bb711e143327ea6ad8ae1d7f73d1994237a7372524d530aebb71880f7b801b4d36fa79be1a94d1326a", "dest": "offline-repository/com/android/tools/ddms/ddmlib/31.13.2", "dest-filename": "ddmlib-31.13.2.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/dvlib/31.13.2/dvlib-31.13.2.jar", "sha512": "3ffe3b1b18bcedd4aa6c4d774ce10ba19574f34290cc56c921f4c6d8d69c19f4346d5ec1ff1a294444ede3a5276402de9dd2782b412cced5a0cba8f73407797b", "dest": "offline-repository/com/android/tools/dvlib/31.13.2", "dest-filename": "dvlib-31.13.2.jar" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/dvlib/31.13.2/dvlib-31.13.2.pom", "sha512": "7e3e845fe1c49effdb29c9d4c658d100466b3e712d9e90c5d019bcd78a962ecfabb302772a3af2a1bffe90ce7672653bed49431166529a3a5696c1ab97a6e397", "dest": "offline-repository/com/android/tools/dvlib/31.13.2", "dest-filename": "dvlib-31.13.2.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/layoutlib/layoutlib-api/31.13.2/layoutlib-api-31.13.2.jar", "sha512": "3a177db40cf73661e1e640391fe54eed921314c666e22d749e1b8148eb1ec83818d52fbaad26df8ad1620194ec51aae76db8af04a4e963d46d6bafa43e842ab9", "dest": "offline-repository/com/android/tools/layoutlib/layoutlib-api/31.13.2", "dest-filename": "layoutlib-api-31.13.2.jar" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/layoutlib/layoutlib-api/31.13.2/layoutlib-api-31.13.2.pom", "sha512": "8f71ef881efb5780ef6062821cc284f0926d452c4b4b7e04a6d090dd93996b75b6bca3bcd350088a282414311ac8515eb4111703c7c8ba0acdc4ec9d3e4e5f0f", "dest": "offline-repository/com/android/tools/layoutlib/layoutlib-api/31.13.2", "dest-filename": "layoutlib-api-31.13.2.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/lint/lint-model/31.13.2/lint-model-31.13.2.jar", "sha512": "bf2e3bee36c76d53fa6d7d5f83af53e4ff8eb37eb5e83657fa3d10bb87825d2240ddd226428573ff44873f025d6fab78a81eb8ead649442357d580530c07c1b7", "dest": "offline-repository/com/android/tools/lint/lint-model/31.13.2", "dest-filename": "lint-model-31.13.2.jar" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/lint/lint-model/31.13.2/lint-model-31.13.2.pom", "sha512": "b8888bebc6fd338cb23e6eccd3528544d5de645be5208587f537efd33706dad2d3dd0dcd2e558b0f2f36f324a4f139ac0e1733b20ad1ec44d5c91f1e73f6771e", "dest": "offline-repository/com/android/tools/lint/lint-model/31.13.2", "dest-filename": "lint-model-31.13.2.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/lint/lint-typedef-remover/31.13.2/lint-typedef-remover-31.13.2.jar", "sha512": "a715c531216f0f5d23b0a78342997452c0b19c4f396bdb631a009efbfbf3e114c25933110350806493b2d77db907f07033096aafc3d48410fb5486106391697f", "dest": "offline-repository/com/android/tools/lint/lint-typedef-remover/31.13.2", "dest-filename": "lint-typedef-remover-31.13.2.jar" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/lint/lint-typedef-remover/31.13.2/lint-typedef-remover-31.13.2.pom", "sha512": "592d7b49796425b53c329bd86658e1c10250b2bcd7175f01c9d6867f1b72ad46b22cba66c57099a6efa8c527e74404bef517907140a159152646d8fa8c09d7dc", "dest": "offline-repository/com/android/tools/lint/lint-typedef-remover/31.13.2", "dest-filename": "lint-typedef-remover-31.13.2.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/repository/31.13.2/repository-31.13.2.jar", "sha512": "0aa3faca5a5c60e1c97edba92af8dd2d7529d50e2c5dbf38af39d5569acba2a754406b298248a7f30ff37a391a0243c749534c65de06994ecbdb3ad833ba2891", "dest": "offline-repository/com/android/tools/repository/31.13.2", "dest-filename": "repository-31.13.2.jar" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/repository/31.13.2/repository-31.13.2.pom", "sha512": "7936e6a53d51c08f5ba01f96f26083cc192747e22633969b12b760f4051c3007d3def9483e8dd6bae82f87a961b3f09c903131664a9a5866e91ac7874cd26bff", "dest": "offline-repository/com/android/tools/repository/31.13.2", "dest-filename": "repository-31.13.2.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/sdk-common/31.13.2/sdk-common-31.13.2.jar", "sha512": "f172c3ce6668721b96aa2e076605015f2d07290a9e365ca5b960f25d46aa72382faff1b3a75b995462e15ef613529d4c540338c406d5f5885e530e1198925e8f", "dest": "offline-repository/com/android/tools/sdk-common/31.13.2", "dest-filename": "sdk-common-31.13.2.jar" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/sdk-common/31.13.2/sdk-common-31.13.2.pom", "sha512": "1423934bff3a691d8fecf71a1742ea538783fcce0a39dfed4bb1f9583a6c5e2b67bb235fc9702137bc10400a38a2b4508cf757b075ad8c89b7b78566cf6edb49", "dest": "offline-repository/com/android/tools/sdk-common/31.13.2", "dest-filename": "sdk-common-31.13.2.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/sdklib/31.13.2/sdklib-31.13.2.jar", "sha512": "cf07a4d842aa87c04cbc225c4f747479bc67eab9fb6825db37b6087a425006f73830b66b46d96f521644414aa3ba133205f86da197295eef52cd0d153f1c2e91", "dest": "offline-repository/com/android/tools/sdklib/31.13.2", "dest-filename": "sdklib-31.13.2.jar" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/sdklib/31.13.2/sdklib-31.13.2.pom", "sha512": "3e177dd693e37c509d8b3231bd809af4a26d18a90f1448bbfb5977e29e30d4398da0fff30266d4bcfc5d022a825c2e591b83aa6d987cb06494c170a2ae87f881", "dest": "offline-repository/com/android/tools/sdklib/31.13.2", "dest-filename": "sdklib-31.13.2.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/utp/android-device-provider-ddmlib-proto/31.13.2/android-device-provider-ddmlib-proto-31.13.2.jar", "sha512": "1ccf5d72b1137a2e1fd76363d5dff75821fab87bb83db1ce19e8052a453aa0594666e3ae858d0c720a22483ee8b2f0971dba45e22f7907de22468a35997090a6", "dest": "offline-repository/com/android/tools/utp/android-device-provider-ddmlib-proto/31.13.2", "dest-filename": "android-device-provider-ddmlib-proto-31.13.2.jar" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/utp/android-device-provider-ddmlib-proto/31.13.2/android-device-provider-ddmlib-proto-31.13.2.pom", "sha512": "e6d2ab25509e43367b799bf96ab971a04c7cbbbf33d94d5ef49df39f483554964273c3a5efafa5d2710f617573c5a03ffcd1ce581cfef81a339ce52fe69bd582", "dest": "offline-repository/com/android/tools/utp/android-device-provider-ddmlib-proto/31.13.2", "dest-filename": "android-device-provider-ddmlib-proto-31.13.2.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/utp/android-device-provider-profile-proto/31.13.2/android-device-provider-profile-proto-31.13.2.jar", "sha512": "f01ddf4e7884fb9811bb8c6a65641617e28cb7d5457ab99740522cf83f49dfe129c1e85e41ec7c71a76ef626d08311c8b2c2603c7b0da7c66db14365204df50e", "dest": "offline-repository/com/android/tools/utp/android-device-provider-profile-proto/31.13.2", "dest-filename": "android-device-provider-profile-proto-31.13.2.jar" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/utp/android-device-provider-profile-proto/31.13.2/android-device-provider-profile-proto-31.13.2.pom", "sha512": "ec048731e474567a8bce094000e0d2b7831266dbe10ef1ab6ac1a159fefe10236efec3cba5e6c67ae1ff3936d6a369cd90cd06d37be207b71915070c51d87088", "dest": "offline-repository/com/android/tools/utp/android-device-provider-profile-proto/31.13.2", "dest-filename": "android-device-provider-profile-proto-31.13.2.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/utp/android-test-plugin-host-additional-test-output-proto/31.13.2/android-test-plugin-host-additional-test-output-proto-31.13.2.jar", "sha512": "27057dd8cf1af00835f478ed518d3ce0ec1e94080879464996bf2e718ad640177c68a6ab542f5df6b3c8ef86de4e3d3cc7a150b090f06516758c20f9e564177b", "dest": "offline-repository/com/android/tools/utp/android-test-plugin-host-additional-test-output-proto/31.13.2", "dest-filename": "android-test-plugin-host-additional-test-output-proto-31.13.2.jar" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/utp/android-test-plugin-host-additional-test-output-proto/31.13.2/android-test-plugin-host-additional-test-output-proto-31.13.2.pom", "sha512": "5097c0187dc392e1dc6f1c72eba44d832d63f6ca03c511722ac70c48d47d30459091467f735c97f9f28f3bc275166056fbf92f2f1f8c8b6feb4aac7592c2df11", "dest": "offline-repository/com/android/tools/utp/android-test-plugin-host-additional-test-output-proto/31.13.2", "dest-filename": "android-test-plugin-host-additional-test-output-proto-31.13.2.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/utp/android-test-plugin-host-apk-installer-proto/31.13.2/android-test-plugin-host-apk-installer-proto-31.13.2.jar", "sha512": "58407e505a8970015374adaeeca50647915e301f948f9260375daf17b90a7bf9228634fb691eeedbdeb4af1519bf2ffc0888bf26f5490115e2a72879c12c8099", "dest": "offline-repository/com/android/tools/utp/android-test-plugin-host-apk-installer-proto/31.13.2", "dest-filename": "android-test-plugin-host-apk-installer-proto-31.13.2.jar" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/utp/android-test-plugin-host-apk-installer-proto/31.13.2/android-test-plugin-host-apk-installer-proto-31.13.2.pom", "sha512": "5aa8f5655a8259646a3802e6ecfc4448bdbe5669764df86f1dfdba83cacedb35dc34867f67aad6fd63f6863a3402b6e595f2a9fb062a0835648b0e80f4442788", "dest": "offline-repository/com/android/tools/utp/android-test-plugin-host-apk-installer-proto/31.13.2", "dest-filename": "android-test-plugin-host-apk-installer-proto-31.13.2.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/utp/android-test-plugin-host-coverage-proto/31.13.2/android-test-plugin-host-coverage-proto-31.13.2.jar", "sha512": "4b577cd576b15a72219c8fec1f991808ae67711c07e1cf3e28ef8450fcacdabd909bc4c417f3b66fe991165b2a8d22a6a927b0247f39f314add9b040b3e5dd5f", "dest": "offline-repository/com/android/tools/utp/android-test-plugin-host-coverage-proto/31.13.2", "dest-filename": "android-test-plugin-host-coverage-proto-31.13.2.jar" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/utp/android-test-plugin-host-coverage-proto/31.13.2/android-test-plugin-host-coverage-proto-31.13.2.pom", "sha512": "831f7b879fff16741c00a4d6df465f9cf2a52d4ad8ce50cf791bf24e897ad77a7240f7546a4cec4da0cecb6ef454b29908f0453dda484725743372e79e4f9481", "dest": "offline-repository/com/android/tools/utp/android-test-plugin-host-coverage-proto/31.13.2", "dest-filename": "android-test-plugin-host-coverage-proto-31.13.2.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/utp/android-test-plugin-host-emulator-control-proto/31.13.2/android-test-plugin-host-emulator-control-proto-31.13.2.jar", "sha512": "421d48cec745881a9634f65321bf215a518b67be1c420d3f2b9a243b95a8891117d62b3a6e1bb6a3edf8a7ce19ac1522240d93e8f82e83e7fbfe5e88c2d0d457", "dest": "offline-repository/com/android/tools/utp/android-test-plugin-host-emulator-control-proto/31.13.2", "dest-filename": "android-test-plugin-host-emulator-control-proto-31.13.2.jar" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/utp/android-test-plugin-host-emulator-control-proto/31.13.2/android-test-plugin-host-emulator-control-proto-31.13.2.pom", "sha512": "84e867844d3605f0f4a64cbeb930771a11a392da654164b13a4e34a5b55e092ba194fea7376d883cb5245afd8f19374f13f914a654beadb517963686666675ba", "dest": "offline-repository/com/android/tools/utp/android-test-plugin-host-emulator-control-proto/31.13.2", "dest-filename": "android-test-plugin-host-emulator-control-proto-31.13.2.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/utp/android-test-plugin-host-logcat-proto/31.13.2/android-test-plugin-host-logcat-proto-31.13.2.jar", "sha512": "1d2161091825c9841a99b38970314f932fa8c0e0a3f132afb0e2290700388a5487564ebadb42a1c4bb20a71c138058dfff6fe50dfd026172f7337f45ea46d466", "dest": "offline-repository/com/android/tools/utp/android-test-plugin-host-logcat-proto/31.13.2", "dest-filename": "android-test-plugin-host-logcat-proto-31.13.2.jar" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/utp/android-test-plugin-host-logcat-proto/31.13.2/android-test-plugin-host-logcat-proto-31.13.2.pom", "sha512": "d6578f73ae9a1c922bee4e09ae54784771a08e4f8cc859b7bc12c83334794e63fa593e1e978ab768d767852047271b5af865562a61f55bcd437b1f4e900929e0", "dest": "offline-repository/com/android/tools/utp/android-test-plugin-host-logcat-proto/31.13.2", "dest-filename": "android-test-plugin-host-logcat-proto-31.13.2.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/utp/android-test-plugin-result-listener-gradle-proto/31.13.2/android-test-plugin-result-listener-gradle-proto-31.13.2.jar", "sha512": "19e764c50ecd46d33e05c8dfd1917e9bdb782725bf572e014ef58d597c049e1531a594712608177006c51066768909a5f050d4224dfcd626027d944fe81cad8a", "dest": "offline-repository/com/android/tools/utp/android-test-plugin-result-listener-gradle-proto/31.13.2", "dest-filename": "android-test-plugin-result-listener-gradle-proto-31.13.2.jar" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/tools/utp/android-test-plugin-result-listener-gradle-proto/31.13.2/android-test-plugin-result-listener-gradle-proto-31.13.2.pom", "sha512": "9656f5670d085e081bb2c94f3132e9ebe1f4045898a4cdedd5d6fb441aa9bd60628a0b42bc4a0f5624d6eb6b060600d0ed90d78be152c48757a4c9e4fb6e4206", "dest": "offline-repository/com/android/tools/utp/android-test-plugin-result-listener-gradle-proto/31.13.2", "dest-filename": "android-test-plugin-result-listener-gradle-proto-31.13.2.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/zipflinger/8.13.2/zipflinger-8.13.2.jar", "sha512": "d101991a9ab84eb9b116a29ba24c8a1d36541f7fe94c27c6fe3658c31ab82f1aa9807e5f54fb4e2776e06658bf2f59a5384513ff3293b578bba257f2aa367c71", "dest": "offline-repository/com/android/zipflinger/8.13.2", "dest-filename": "zipflinger-8.13.2.jar" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/android/zipflinger/8.13.2/zipflinger-8.13.2.pom", "sha512": "b8378dde5097e6551034c363b94603218f8c9b8f86e6a611ac50db6d2e548f81a7895afee54e958371202a22c0cfa15b5bd5f842ad3d1560d514305da59cff15", "dest": "offline-repository/com/android/zipflinger/8.13.2", "dest-filename": "zipflinger-8.13.2.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/android/annotations/4.1.1.4/annotations-4.1.1.4.jar", "sha512": "530bfa9e7aea7b2dc2e8776f083705f12772045f6f4bbe235a1c3e97646bd0b0a367358aab0f129058d1899573f4bce97d7db3dfff96dfdabc99377c5d837222", "dest": "offline-repository/com/google/android/annotations/4.1.1.4", "dest-filename": "annotations-4.1.1.4.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/android/annotations/4.1.1.4/annotations-4.1.1.4.pom", "sha512": "6727ecc1bc63112555f4ff8013d0b136f44641d73673e03e89ccb45d6b918c05717ff32065088d72e94b7b6600e9536b83e96faa842bcb837cb34aff7eebe88e", "dest": "offline-repository/com/google/android/annotations/4.1.1.4", "dest-filename": "annotations-4.1.1.4.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/api/grpc/proto-google-common-protos/2.48.0/proto-google-common-protos-2.48.0.jar", "sha512": "201eea4fa42a6c109ff14af3a8bb4f38de46442d4ecf6ee2b4ac116e4b91fff88127f6eb2acebf6d86afca6db67cbed3aca2a0132a05f56e6151206b5598fdb8", "dest": "offline-repository/com/google/api/grpc/proto-google-common-protos/2.48.0", "dest-filename": "proto-google-common-protos-2.48.0.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/api/grpc/proto-google-common-protos/2.48.0/proto-google-common-protos-2.48.0.pom", "sha512": "0b2562159ce9019052cc45a7e4ccdb4ae28e36a5a83ef641cd4316566fdd66fcf5906c03c10a1f03c12d3d63607537211f26cde290e6553817285981428293ac", "dest": "offline-repository/com/google/api/grpc/proto-google-common-protos/2.48.0", "dest-filename": "proto-google-common-protos-2.48.0.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/auto/auto-parent/6/auto-parent-6.pom", "sha512": "5766f2b3a848a39440ff27d6985c6a88500c97511ec3634d53d547e53ad58c944a6f73128dc3f4e8286972b6161e65d46517694a2dbefcab18bb966ed7011492", "dest": "offline-repository/com/google/auto/auto-parent/6", "dest-filename": "auto-parent-6.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/auto/value/auto-value-annotations/1.6.2/auto-value-annotations-1.6.2.jar", "sha512": "37fc4689f2885261fecee345300934025fd65b9e43a65a696c9046c3d2919c1c09f30dcbe0b74099f9307a5248dd08030edd4233637e3db3ab40a16b30cb23b4", "dest": "offline-repository/com/google/auto/value/auto-value-annotations/1.6.2", "dest-filename": "auto-value-annotations-1.6.2.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/auto/value/auto-value-annotations/1.6.2/auto-value-annotations-1.6.2.pom", "sha512": "d9c83a387f0b6285fbcda3ba32dc6e888f2876a3eaec9a0a15c04517466f72c449784e5829503fb74ae988e353829709f6b2f7467b0450a942ed33caa96380ab", "dest": "offline-repository/com/google/auto/value/auto-value-annotations/1.6.2", "dest-filename": "auto-value-annotations-1.6.2.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/auto/value/auto-value-parent/1.6.2/auto-value-parent-1.6.2.pom", "sha512": "1c467407114d4eda18a71eca2093f6439b1bc7ed0c2e4f01df1975d45b7b261fb7aee09376b35f3c9928498b527c9ea4b1e9622b18e3478bf481fd64947db978", "dest": "offline-repository/com/google/auto/value/auto-value-parent/1.6.2", "dest-filename": "auto-value-parent-1.6.2.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/code/findbugs/jsr305/3.0.2/jsr305-3.0.2.jar", "sha512": "bb09db62919a50fa5b55906013be6ca4fc7acb2e87455fac5eaf9ede2e41ce8bbafc0e5a385a561264ea4cd71bbbd3ef5a45e02d63277a201d06a0ae1636f804", "dest": "offline-repository/com/google/code/findbugs/jsr305/3.0.2", "dest-filename": "jsr305-3.0.2.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/code/findbugs/jsr305/3.0.2/jsr305-3.0.2.pom", "sha512": "08e1cc341a153f64b670d87831eedfe79a150b8fb7e3a4afbaef54deaa28d2767ae86d12b4f0c5404184360ab8e48a3655e610f3bf2fe6c97a06e9fc3df49b37", "dest": "offline-repository/com/google/code/findbugs/jsr305/3.0.2", "dest-filename": "jsr305-3.0.2.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/code/gson/gson-parent/2.13.2/gson-parent-2.13.2.pom", "sha512": "e6524dbb8aa13707fa51df7413a8b247a4de248812e212702e4e5888d0df29c6ebe39a3b1e09aefb12a1a095c42d19d074756a30918bfc4c1fe55c39b9a7c1fa", "dest": "offline-repository/com/google/code/gson/gson-parent/2.13.2", "dest-filename": "gson-parent-2.13.2.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/code/gson/gson/2.13.2/gson-2.13.2.jar", "sha512": "8974a052656d2e5ec968b6bac2edf51413ffc62040fdc65f14f00597e738ab544d22487f8579ba90618b5a7f94ef33773510fac67b408fee6ed274b38f3d9947", "dest": "offline-repository/com/google/code/gson/gson/2.13.2", "dest-filename": "gson-2.13.2.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/code/gson/gson/2.13.2/gson-2.13.2.pom", "sha512": "c0d089089bf0d6bcfae008f329dcc2db2538d02654f8bc7690cb8db2ec94fe0d2aa069ac1d70a111a28cced0ffd7742ac8db29a884d64d9413ebf9b2af5dab82", "dest": "offline-repository/com/google/code/gson/gson/2.13.2", "dest-filename": "gson-2.13.2.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/crypto/tink/tink/1.7.0/tink-1.7.0.jar", "sha512": "bcc1da6501249bdcd4b8053b7220175ed922887240358ed6e6846b1cfdba813ee9c854877065b3e9540a4c456eccd5841d5a485eee6d0228cf838f2b9cce1037", "dest": "offline-repository/com/google/crypto/tink/tink/1.7.0", "dest-filename": "tink-1.7.0.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/crypto/tink/tink/1.7.0/tink-1.7.0.pom", "sha512": "1dd30bd9ba5060dee08f35a88358bbd009499448d9d595a1a04afb932a785531d78b514a39e404f3e95f5c06ffa6b2c26a675b12175ae944ca08c811ee649074", "dest": "offline-repository/com/google/crypto/tink/tink/1.7.0", "dest-filename": "tink-1.7.0.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/dagger/dagger/2.28.3/dagger-2.28.3.jar", "sha512": "0ab9b4b48db3393127c4979f7f9ed9af63a1d9ceab8eade10ba9c6a7f480204c9951740fe400e8940afb28089d4915ab8670565cd381c70d4b10ffb65e3383dc", "dest": "offline-repository/com/google/dagger/dagger/2.28.3", "dest-filename": "dagger-2.28.3.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/dagger/dagger/2.28.3/dagger-2.28.3.pom", "sha512": "51cd57eec1b311890e7d7fb3706a7202f7fcb2252e91f8979800f48225ce8cfd429811c95c9ea05d3a7fb6519a61279604dc6c1d27666da10d8a6d61747477de", "dest": "offline-repository/com/google/dagger/dagger/2.28.3", "dest-filename": "dagger-2.28.3.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/devtools/ksp/com.google.devtools.ksp.gradle.plugin/2.3.5/com.google.devtools.ksp.gradle.plugin-2.3.5.pom", "sha512": "113c90ac4da3a1d6a38942565e8913db13b91c2e6d51144f5843e021ef384f2efa47cf859eb248c42cd8b824ddc1741bf2959c950795619c18566b92bbc4e11b", "dest": "offline-repository/com/google/devtools/ksp/com.google.devtools.ksp.gradle.plugin/2.3.5", "dest-filename": "com.google.devtools.ksp.gradle.plugin-2.3.5.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/devtools/ksp/symbol-processing-api/2.3.5/symbol-processing-api-2.3.5.jar", "sha512": "c72ae0d465aa7b931b8e778205eb73a05df12c9fc0658ecbf74fee0a5b14f5aa1c0db8ed61a400d21e171898a0aa75540c2e75367e831c30939aa4a2a7999226", "dest": "offline-repository/com/google/devtools/ksp/symbol-processing-api/2.3.5", "dest-filename": "symbol-processing-api-2.3.5.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/devtools/ksp/symbol-processing-api/2.3.5/symbol-processing-api-2.3.5.module", "sha512": "8d021789a6999a0cb782120276b482167f4c6954902558f13a099f762c65a5c43c2b68b412991fdbc462b427f688e5315c41647c90bbf1f422f01b3b609ec70c", "dest": "offline-repository/com/google/devtools/ksp/symbol-processing-api/2.3.5", "dest-filename": "symbol-processing-api-2.3.5.module" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/devtools/ksp/symbol-processing-api/2.3.5/symbol-processing-api-2.3.5.pom", "sha512": "add5ae3eb926f825ab2897e3e1cc14bbc71ea5907dc73c3701c189a2cd7b4c9afc8f8d9afb88cd6346122975841cd7a5c502042c4ef2f509fc0212b85615f3f8", "dest": "offline-repository/com/google/devtools/ksp/symbol-processing-api/2.3.5", "dest-filename": "symbol-processing-api-2.3.5.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/devtools/ksp/symbol-processing-common-deps/2.3.5/symbol-processing-common-deps-2.3.5.jar", "sha512": "d689128336ee69d8361b4c7532c9b6944d99a12ff20c78b86f8270ce9a5973196b1bddab93b8c5ed89dc92211a800f5827adaa3435b8ea1d11b67977a59d4114", "dest": "offline-repository/com/google/devtools/ksp/symbol-processing-common-deps/2.3.5", "dest-filename": "symbol-processing-common-deps-2.3.5.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/devtools/ksp/symbol-processing-common-deps/2.3.5/symbol-processing-common-deps-2.3.5.module", "sha512": "78b8df83649dfa077485476cefcc36931e91c4dc61c7047d0918421525cd9e1adabf14cfaf5d17a2cb8c9239e72b835525784e2d142bc8a037fb3568ad465103", "dest": "offline-repository/com/google/devtools/ksp/symbol-processing-common-deps/2.3.5", "dest-filename": "symbol-processing-common-deps-2.3.5.module" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/devtools/ksp/symbol-processing-common-deps/2.3.5/symbol-processing-common-deps-2.3.5.pom", "sha512": "c05835ab63fd7d34c3cf3066ebfa7da5b5887e35e607dd8ee4d6fb395860bef8bacdadd2ca15c684f769a3da4fdf245b7a55a1943591235b44b7fbfc44f03f1c", "dest": "offline-repository/com/google/devtools/ksp/symbol-processing-common-deps/2.3.5", "dest-filename": "symbol-processing-common-deps-2.3.5.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/devtools/ksp/symbol-processing-gradle-plugin/2.3.5/symbol-processing-gradle-plugin-2.3.5.jar", "sha512": "e86a80253588a025e6e297e5a5f3c5ee32ea567b99ea997d11e7953f87048fa63edaa950b0f192e3ea2450c14973672fd0ee32779e022308afdf4433a94abeff", "dest": "offline-repository/com/google/devtools/ksp/symbol-processing-gradle-plugin/2.3.5", "dest-filename": "symbol-processing-gradle-plugin-2.3.5.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/devtools/ksp/symbol-processing-gradle-plugin/2.3.5/symbol-processing-gradle-plugin-2.3.5.module", "sha512": "8b202cdf60d1bbf8b22afac91e1f8987ec2337753e46941b6c8ca0019e56e5c34d3be2baeb70f93ac7c816f7e5f43defb443d2747fdc29f0751e3223c98b38d0", "dest": "offline-repository/com/google/devtools/ksp/symbol-processing-gradle-plugin/2.3.5", "dest-filename": "symbol-processing-gradle-plugin-2.3.5.module" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/devtools/ksp/symbol-processing-gradle-plugin/2.3.5/symbol-processing-gradle-plugin-2.3.5.pom", "sha512": "b9c4d3211cf76694e3a3e5f44b9ee13fbf9481d8ff26f0a72036a031a4bd9dce5d6e760387a9ca7bce5f58e9dd5d8cb55dcc0d2701e402e8102d432d54a058c8", "dest": "offline-repository/com/google/devtools/ksp/symbol-processing-gradle-plugin/2.3.5", "dest-filename": "symbol-processing-gradle-plugin-2.3.5.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/errorprone/error_prone_annotations/2.41.0/error_prone_annotations-2.41.0.jar", "sha512": "e2eb4bf9f36f95a4d4c5ea344db5cd90a456e63bef8e52932b8f6f4ecfdd59cb2f6c2ce9e67b0070c82177e42885688b95afef591b16001f789b378f18afdf30", "dest": "offline-repository/com/google/errorprone/error_prone_annotations/2.41.0", "dest-filename": "error_prone_annotations-2.41.0.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/errorprone/error_prone_annotations/2.41.0/error_prone_annotations-2.41.0.pom", "sha512": "07bb0fc1b8659cd8c9ffb49563d4ec5f86bb30f5515eb6951e272de83e64670b0490f2e40b33ff6673f0d603719430c3378630478686d5849242028285204cdc", "dest": "offline-repository/com/google/errorprone/error_prone_annotations/2.41.0", "dest-filename": "error_prone_annotations-2.41.0.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/errorprone/error_prone_parent/2.41.0/error_prone_parent-2.41.0.pom", "sha512": "ab85819cbab15554b3dbc1fd75eac6a13440178ee8c92a2b0fceeecac970f464d3aafc017842a2f7a692d15e7875389eb31ae9c0e08844990d7104f6c2f23f2a", "dest": "offline-repository/com/google/errorprone/error_prone_parent/2.41.0", "dest-filename": "error_prone_parent-2.41.0.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/flatbuffers/flatbuffers-java/1.12.0/flatbuffers-java-1.12.0.jar", "sha512": "20750e91441c074ca28f72b0b6494e8f0bfbc94caff04eb49727efc6b28e453c84732f24acc52cf36b36ba66b57a145d003e87f749c5c4518ddc6f9247b8ab47", "dest": "offline-repository/com/google/flatbuffers/flatbuffers-java/1.12.0", "dest-filename": "flatbuffers-java-1.12.0.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/flatbuffers/flatbuffers-java/1.12.0/flatbuffers-java-1.12.0.pom", "sha512": "57d6a98dbd649ad67f3a26fc5a473cdeabd8e0dbec03ce0d95e97d302c074399690ad96442fa412ab5780e9a1d612ea07e441fff3d958e265daea2aba2acfa33", "dest": "offline-repository/com/google/flatbuffers/flatbuffers-java/1.12.0", "dest-filename": "flatbuffers-java-1.12.0.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/guava/failureaccess/1.0.2/failureaccess-1.0.2.jar", "sha512": "ff4ee76aa661708989d53d45576cff3beea9ebbd86481dbbf2ee8c81bb22f882097b430588312b711025f0e890f22c6799d722ccd422a6a7278de08660fe2f51", "dest": "offline-repository/com/google/guava/failureaccess/1.0.2", "dest-filename": "failureaccess-1.0.2.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/guava/failureaccess/1.0.2/failureaccess-1.0.2.pom", "sha512": "4e5144a31143d0ee374dc323752d57c28d7a0117abcf75a67397ba1a26c93dcf2c248c357d52c4ce75e2fe7c366df909a0a77db5775cc30c92c4e72a433566af", "dest": "offline-repository/com/google/guava/failureaccess/1.0.2", "dest-filename": "failureaccess-1.0.2.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/guava/guava-parent/26.0-android/guava-parent-26.0-android.pom", "sha512": "1d786f14fbfa5c90eedcc160d1e0a71acb2141f372049b22ce62b0bd1e883c17cc24a59dc8b00e5037e959cccdb54d4d8dc8f252302d4bb7ce82dfdaff764476", "dest": "offline-repository/com/google/guava/guava-parent/26.0-android", "dest-filename": "guava-parent-26.0-android.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/guava/guava-parent/33.3.1-jre/guava-parent-33.3.1-jre.pom", "sha512": "3927dd059c1f983b7a390b08f93f49931231a20a5c77b6b94eca6e536df1a5c1d8f2248ed9e906e3fc621f2c44421c5dee9e82f8a722d9cea1e35a86cfc28a66", "dest": "offline-repository/com/google/guava/guava-parent/33.3.1-jre", "dest-filename": "guava-parent-33.3.1-jre.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/guava/guava/33.3.1-jre/guava-33.3.1-jre.jar", "sha512": "5fdb406eeafbb7b409bae82924b7c26bdcdea06c7edbb673901814c3d35010bc91139e5139663af0b66f8fa47f54e0e96dd86fdff1797c9ae497c10a466b02e3", "dest": "offline-repository/com/google/guava/guava/33.3.1-jre", "dest-filename": "guava-33.3.1-jre.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/guava/guava/33.3.1-jre/guava-33.3.1-jre.module", "sha512": "9ba76c1e924406aefe5e3a37e536fa4301a038fc671605eb7d2ffde7723e25fdbe6e26b6a297d5fe74cee8790fc0776789865254a30a1c87c7c00c499a2c4aae", "dest": "offline-repository/com/google/guava/guava/33.3.1-jre", "dest-filename": "guava-33.3.1-jre.module" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/guava/guava/33.3.1-jre/guava-33.3.1-jre.pom", "sha512": "edc26505ae9ec67ea167996de4ca09a095bd341d19a004241332c59992dca53e21c07814d0dd5f33ba7934659e7ae1ac85ec74ef7da248074dfe35aa55ba14e8", "dest": "offline-repository/com/google/guava/guava/33.3.1-jre", "dest-filename": "guava-33.3.1-jre.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/guava/listenablefuture/9999.0-empty-to-avoid-conflict-with-guava/listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.jar", "sha512": "c5987a979174cbacae2e78b319f080420cc71bcdbcf7893745731eeb93c23ed13bff8d4599441f373f3a246023d33df03e882de3015ee932a74a774afdd0782f", "dest": "offline-repository/com/google/guava/listenablefuture/9999.0-empty-to-avoid-conflict-with-guava", "dest-filename": "listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/guava/listenablefuture/9999.0-empty-to-avoid-conflict-with-guava/listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.pom", "sha512": "140544970539199c860d5e7e2d8096bbccd55980602773f29e8d74ab4f3e24c43aa369b0c7d06b026ba7a9db3353be285d7ffb9cd94b2bedf62820c3fdfd1d5f", "dest": "offline-repository/com/google/guava/listenablefuture/9999.0-empty-to-avoid-conflict-with-guava", "dest-filename": "listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/j2objc/j2objc-annotations/3.0.0/j2objc-annotations-3.0.0.jar", "sha512": "1406b1aa53b19f8269129d96ce8b64bf36f215eacf7d8f1e0adadee31614e53bb3f7acf4ff97418c5bfc75677a6f3cd637c3d9889d1e85117b6fa12467c91e9f", "dest": "offline-repository/com/google/j2objc/j2objc-annotations/3.0.0", "dest-filename": "j2objc-annotations-3.0.0.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/j2objc/j2objc-annotations/3.0.0/j2objc-annotations-3.0.0.pom", "sha512": "3c0f19b8e1275dbbaf8a0d02f9ef27a7edbb73ffdd75ae2c9fd334b5dbc373e8420f896b962c86f5d3540007c07acd1995eed8d119d7c6e7068305e9c12f0181", "dest": "offline-repository/com/google/j2objc/j2objc-annotations/3.0.0", "dest-filename": "j2objc-annotations-3.0.0.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/jimfs/jimfs-parent/1.1/jimfs-parent-1.1.pom", "sha512": "83091a57b7436eef0f1452405358ce87d85813bb59275ff9b51b5b6f23b10627138e5077f4611ffc828845445107607e08648cb28dce50b8b1746c09329783d9", "dest": "offline-repository/com/google/jimfs/jimfs-parent/1.1", "dest-filename": "jimfs-parent-1.1.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/jimfs/jimfs/1.1/jimfs-1.1.jar", "sha512": "a915da137c45e2ce1aca552b3658545a50c893c9dc971a1992d1e05b9c7901ee22d5b19f9489353ed4de149a8a72d150e1605ad164e52ce4ff97753969794751", "dest": "offline-repository/com/google/jimfs/jimfs/1.1", "dest-filename": "jimfs-1.1.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/jimfs/jimfs/1.1/jimfs-1.1.pom", "sha512": "af3cabfa14bac0d46470416b4a2747404cf6ec9da96688ede925131ffba3da6a74f1b73db4aa6a44ea11f90ded4f308aa4e8bd16c9581b9a7ed95d79aa330384", "dest": "offline-repository/com/google/jimfs/jimfs/1.1", "dest-filename": "jimfs-1.1.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/protobuf/protobuf-bom/3.25.5/protobuf-bom-3.25.5.pom", "sha512": "b900a81cfd33a1e85c903ef0eeea375a1e5d85cc6ccb3470b7bbbcceaf928e9e8670405a546aafe2cd0e7e9ed565628eb0990bcb1ae643e0c394f2745cd84007", "dest": "offline-repository/com/google/protobuf/protobuf-bom/3.25.5", "dest-filename": "protobuf-bom-3.25.5.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/protobuf/protobuf-java-util/3.25.5/protobuf-java-util-3.25.5.jar", "sha512": "bd9411eba3911839ded3dc26536e3383f3135738ec0f17ecd29681f99cbc8dd50dfce73b7da9ab80eca6adea5271cff337192e7d96fea17fe27ee92e69a4d07f", "dest": "offline-repository/com/google/protobuf/protobuf-java-util/3.25.5", "dest-filename": "protobuf-java-util-3.25.5.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/protobuf/protobuf-java-util/3.25.5/protobuf-java-util-3.25.5.pom", "sha512": "7af1dda607c8d3102670c2dc2d130a5a38dc3da4c54d8111e63137fcba924e206333baedb14cdef5a37cf5dbff0e99db92951419837ae676d0ebbdbcc9400acc", "dest": "offline-repository/com/google/protobuf/protobuf-java-util/3.25.5", "dest-filename": "protobuf-java-util-3.25.5.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/protobuf/protobuf-java/3.25.5/protobuf-java-3.25.5.jar", "sha512": "432d8a9359e614d38fe416b7a4564aed3e358fd5f3c2c4f22caf97945a0f3e5cbd2220b690d6b822504e7bcbbd67458eb12d232232dd113f804683a172f8eb71", "dest": "offline-repository/com/google/protobuf/protobuf-java/3.25.5", "dest-filename": "protobuf-java-3.25.5.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/protobuf/protobuf-java/3.25.5/protobuf-java-3.25.5.pom", "sha512": "2e9ea8539bf92bf9ab89e08614fdccd4a663db1f2cf5b4e35f59ec7276a34d9e83e57da51bcd7ef5959016072768a9ba105f78d6c1334dbc9e61f90b37103810", "dest": "offline-repository/com/google/protobuf/protobuf-java/3.25.5", "dest-filename": "protobuf-java-3.25.5.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/google/protobuf/protobuf-parent/3.25.5/protobuf-parent-3.25.5.pom", "sha512": "7470211ceeff2ee79ac892d9f1ed22e698a0f9e07ac1546943bd5abd20e0133de075ec00a5bb230d16ffe900995ef028ff950c7395887ba3108c59ecf1074456", "dest": "offline-repository/com/google/protobuf/protobuf-parent/3.25.5", "dest-filename": "protobuf-parent-3.25.5.pom" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/google/testing/platform/core-proto/0.0.9-alpha03/core-proto-0.0.9-alpha03.jar", "sha512": "39f974779b3a8ab5359c0f499cabd0dd021bdf67e1c5fddf4c2838589e9984b812f52eb095d8b6da749a98ac29400a873ca7f6ffef5063b76fc181a75e129b26", "dest": "offline-repository/com/google/testing/platform/core-proto/0.0.9-alpha03", "dest-filename": "core-proto-0.0.9-alpha03.jar" }, { "type": "file", "url": "https://dl.google.com/dl/android/maven2/com/google/testing/platform/core-proto/0.0.9-alpha03/core-proto-0.0.9-alpha03.pom", "sha512": "55d689847fcb5c4cfe724232491948f26f7790aaabfbf5a2d72e5ad093b8310ba956bd5830b004592fd24df08e6af629f7746d3ce427635e51e5577ee5b7214a", "dest": "offline-repository/com/google/testing/platform/core-proto/0.0.9-alpha03", "dest-filename": "core-proto-0.0.9-alpha03.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/googlecode/juniversalchardet/juniversalchardet/1.0.3/juniversalchardet-1.0.3.jar", "sha512": "62278bb82d1dcbf64e92e5314ff1ce481e34e3d4bcc2aad54d410b339f00b33ceb8aaddf6ed86e839d924f17d25948a0ae4a37015724076048d14e1aa66c942d", "dest": "offline-repository/com/googlecode/juniversalchardet/juniversalchardet/1.0.3", "dest-filename": "juniversalchardet-1.0.3.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/googlecode/juniversalchardet/juniversalchardet/1.0.3/juniversalchardet-1.0.3.pom", "sha512": "0e38ec949810af17ed539dd65159cdf5d605e1b0491e95cd227585d77c0dceca4b41ee642345cdc7b004b4be08d26f15e9b91d0aaf12a654a341b835a9d8d002", "dest": "offline-repository/com/googlecode/juniversalchardet/juniversalchardet/1.0.3", "dest-filename": "juniversalchardet-1.0.3.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/squareup/javapoet/1.10.0/javapoet-1.10.0.jar", "sha512": "67fe213b5ddd71fca4e28758025b34feb82fe7b5e5f50aa9c652714c21fddf0bf6fbd0c21c555e6824f5ff989a1feefc448a0cb376b2fa7c684abff70d51769c", "dest": "offline-repository/com/squareup/javapoet/1.10.0", "dest-filename": "javapoet-1.10.0.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/squareup/javapoet/1.10.0/javapoet-1.10.0.pom", "sha512": "de661d83bd261fa527a5c8447dc14621a638aba12c697f59a393f47175f01235532ec8df5067d5c155351256fb3b07eed28e0c097e263052709e276224cafe81", "dest": "offline-repository/com/squareup/javapoet/1.10.0", "dest-filename": "javapoet-1.10.0.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/squareup/javawriter/2.5.0/javawriter-2.5.0.jar", "sha512": "0d1df927a41b762b30bdc520ae6ea465ded1385a2b0a0d91dfcac8c0d87388089d8a881361060d1df0fe833944864fc23f9fe38c30d9ce106cc3e846f072dcf3", "dest": "offline-repository/com/squareup/javawriter/2.5.0", "dest-filename": "javawriter-2.5.0.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/squareup/javawriter/2.5.0/javawriter-2.5.0.pom", "sha512": "81d5507e44a1ba76c61566abfa1d189f2136960ba638c247494f8c55bd99ac34ea64e88e5d3c8d5ace51c856d36bb941c90bc1170346d8e278d8b5c0006f2954", "dest": "offline-repository/com/squareup/javawriter/2.5.0", "dest-filename": "javawriter-2.5.0.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/sun/activation/all/1.2.0/all-1.2.0.pom", "sha512": "9efe921f24ff6f0e5713f3e327b0029ddaba7bf7da93738ca04f80ccad2f0a26870195ba7eb990d8a026daedd83daf8042137cce9cd71ffeef12ddc329e3163d", "dest": "offline-repository/com/sun/activation/all/1.2.0", "dest-filename": "all-1.2.0.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/sun/activation/all/1.2.1/all-1.2.1.pom", "sha512": "87de9589f96f717f3b2d665aad8eb531ed677aa0b34528c049c9edb964f20b275dda112d24eb7c630ccc1ccf6cead40147f567391a2e99165a0d33b068473beb", "dest": "offline-repository/com/sun/activation/all/1.2.1", "dest-filename": "all-1.2.1.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/sun/activation/javax.activation/1.2.0/javax.activation-1.2.0.jar", "sha512": "b4cbdd8fd1703e4b2e1e691db78fbcf2232d836f740d1821c4c191a14f9472508e27a40d06e4b6b153964af68032959c22945ba169a0ca4018b7748162f420a6", "dest": "offline-repository/com/sun/activation/javax.activation/1.2.0", "dest-filename": "javax.activation-1.2.0.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/sun/activation/javax.activation/1.2.0/javax.activation-1.2.0.pom", "sha512": "a90ba81c3572f2169933f535b10a5b9cf6405e8b812464887ae2b5a2b25532be2a905468796512bd034119883869825b3ac858d51da23d01afab6bb990577ade", "dest": "offline-repository/com/sun/activation/javax.activation/1.2.0", "dest-filename": "javax.activation-1.2.0.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/sun/istack/istack-commons-runtime/3.0.8/istack-commons-runtime-3.0.8.jar", "sha512": "e712166f961cca819a61bd6efc93b93759788d283af73dcc6ed09c0f9c045d7dc2f7fabc0e236b2996fcdebe26e1b2ec5ea45eb4353943dad82ecfef0488305e", "dest": "offline-repository/com/sun/istack/istack-commons-runtime/3.0.8", "dest-filename": "istack-commons-runtime-3.0.8.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/sun/istack/istack-commons-runtime/3.0.8/istack-commons-runtime-3.0.8.pom", "sha512": "d365eced43f3d5743ad798f14ef7715cec40b4b107dd56142fa61b5db5a52b454d312904d6763a8221f47008290e47c5f1546b1e67f0b0f5075734416ad63992", "dest": "offline-repository/com/sun/istack/istack-commons-runtime/3.0.8", "dest-filename": "istack-commons-runtime-3.0.8.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/sun/istack/istack-commons/3.0.8/istack-commons-3.0.8.pom", "sha512": "d032138423fddfeaffe303b01a85a4d68766f1af0af43a8a24d122d665b2016c110b10ce77fcff1851ccfaaabb46e0f3faf6c664c05efd271705829ef8466f06", "dest": "offline-repository/com/sun/istack/istack-commons/3.0.8", "dest-filename": "istack-commons-3.0.8.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/sun/xml/bind/jaxb-bom-ext/2.3.2/jaxb-bom-ext-2.3.2.pom", "sha512": "2445bdddf6f353ee89f81b4a1b392c73f72f867a3a68a611f98e4bcdd608b49aa65dc0f1bacd65c0b95f87dc1d6c02f6c066b6f3b7a5ffb7270bfdc7ee6d482d", "dest": "offline-repository/com/sun/xml/bind/jaxb-bom-ext/2.3.2", "dest-filename": "jaxb-bom-ext-2.3.2.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/sun/xml/bind/mvn/jaxb-parent/2.3.2/jaxb-parent-2.3.2.pom", "sha512": "0464d8a6bea1e3f5e5c5f1e1dd30fbf637a966395421c796a3fc09ce6d9da74a8a0d810ad013c25b946627e663576b6b1d99a1a16048ba0d29dd23935a2d4963", "dest": "offline-repository/com/sun/xml/bind/mvn/jaxb-parent/2.3.2", "dest-filename": "jaxb-parent-2.3.2.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/sun/xml/bind/mvn/jaxb-runtime-parent/2.3.2/jaxb-runtime-parent-2.3.2.pom", "sha512": "6916a297ce970d4e2029ff79a4d4fd6f056520644e64922175194f7810c67c1914ed66e2a6da34c1e518d18a1636ad91fd2d328afd502d55d0f2366b0ccc2020", "dest": "offline-repository/com/sun/xml/bind/mvn/jaxb-runtime-parent/2.3.2", "dest-filename": "jaxb-runtime-parent-2.3.2.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/sun/xml/bind/mvn/jaxb-txw-parent/2.3.2/jaxb-txw-parent-2.3.2.pom", "sha512": "d26ae2bf5e22343350de9c544a13598f404ab659ad88863dbe1f5423a1b96563ee56c0620e71ac0768a921a47ed211256a0d3a4bb9973894f0cac484bcfa0c0c", "dest": "offline-repository/com/sun/xml/bind/mvn/jaxb-txw-parent/2.3.2", "dest-filename": "jaxb-txw-parent-2.3.2.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/sun/xml/fastinfoset/FastInfoset/1.2.16/FastInfoset-1.2.16.jar", "sha512": "cd57377d61d66ac2ab6ab90483252385fe2bbb9e13dee07913362a64ffe97b16082df981dbe469df3f67ad946827be6f4355d63003a393bda62a0fc8b54a590b", "dest": "offline-repository/com/sun/xml/fastinfoset/FastInfoset/1.2.16", "dest-filename": "FastInfoset-1.2.16.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/sun/xml/fastinfoset/FastInfoset/1.2.16/FastInfoset-1.2.16.pom", "sha512": "8c2c6b0d37838ea9a27329af479b0c70cff3b93f78828c3d9a959db99a8c639b2b00fe382e3df14ee7bc4cc63c4cb98ab5e827fc8921b39eb8afce02944669dc", "dest": "offline-repository/com/sun/xml/fastinfoset/FastInfoset/1.2.16", "dest-filename": "FastInfoset-1.2.16.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/com/sun/xml/fastinfoset/fastinfoset-project/1.2.16/fastinfoset-project-1.2.16.pom", "sha512": "07b2ceef7b3062f865fcf88636056bc5b5d071f05c49fa74e8b01ecdf5030ce5845bc065de4c6f479f2ea1e400dd5b45690b7dbb0516fa6460c64b2745fd1af0", "dest": "offline-repository/com/sun/xml/fastinfoset/fastinfoset-project/1.2.16", "dest-filename": "fastinfoset-project-1.2.16.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/commons-codec/commons-codec/1.11/commons-codec-1.11.jar", "sha512": "d9586162b257386b5871e7e9ae255a38014a9efaeef5148de5e40a3b0200364dad8516bddd554352aa2e5337bec2cc11df88c76c4fdde96a40f3421aa60650d7", "dest": "offline-repository/commons-codec/commons-codec/1.11", "dest-filename": "commons-codec-1.11.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/commons-codec/commons-codec/1.11/commons-codec-1.11.pom", "sha512": "1b7a0a986a1bd222c0795972daabee4f16e308498f67d62b99749506b5a71e9811deaa323c080517a8184d6481e8281c91bd012d5614b7d5d2ba14aa96f2596c", "dest": "offline-repository/commons-codec/commons-codec/1.11", "dest-filename": "commons-codec-1.11.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/commons-io/commons-io/2.16.1/commons-io-2.16.1.jar", "sha512": "97eab31b073c5c57c8bcfaa2fec7b481a15a9a1f9ed864dfdc63b57f062b230557caa734c3133aca1165facb588c58db0185c07832241d70159e87a4bcf48008", "dest": "offline-repository/commons-io/commons-io/2.16.1", "dest-filename": "commons-io-2.16.1.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/commons-io/commons-io/2.16.1/commons-io-2.16.1.pom", "sha512": "c7c29f4d44cc8dc1b60c2e29dee585a7eb4941fb7664140c0a22832056bba7cf79ed616911a72703090e72e4a223f24439e5021c3defa37d64e5d533b67d7ee2", "dest": "offline-repository/commons-io/commons-io/2.16.1", "dest-filename": "commons-io-2.16.1.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/commons-logging/commons-logging/1.2/commons-logging-1.2.jar", "sha512": "ed00dbfabd9ae00efa26dd400983601d076fe36408b7d6520084b447e5d1fa527ce65bd6afdcb58506c3a808323d28e88f26cb99c6f5db9ff64f6525ecdfa557", "dest": "offline-repository/commons-logging/commons-logging/1.2", "dest-filename": "commons-logging-1.2.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/commons-logging/commons-logging/1.2/commons-logging-1.2.pom", "sha512": "75bef548eea62ab04569791f2fdeed3d0a61edae0534aa035a905dc1d011988fc0f06f52bde377f44e94e6afd4380197148120b152b7a4d20628fb6236cc7261", "dest": "offline-repository/commons-logging/commons-logging/1.2", "dest-filename": "commons-logging-1.2.pom" }, { "type": "file", "url": "https://plugins.gradle.org/m2/io/github/jwharm/flatpak-gradle-generator/io.github.jwharm.flatpak-gradle-generator.gradle.plugin/1.7.0/io.github.jwharm.flatpak-gradle-generator.gradle.plugin-1.7.0.pom", "sha512": "fba54b6f68e32382288201b0a360a6ba34f5a24bfc2f6ced503384f1bf67de2ca5c4f417a91616c2da591d185202d0c58acee2ea3a1ed2a8810011cdc1fe5056", "dest": "offline-repository/io/github/jwharm/flatpak-gradle-generator/io.github.jwharm.flatpak-gradle-generator.gradle.plugin/1.7.0", "dest-filename": "io.github.jwharm.flatpak-gradle-generator.gradle.plugin-1.7.0.pom" }, { "type": "file", "url": "https://plugins.gradle.org/m2/io/github/jwharm/flatpak-gradle-generator/plugin/1.7.0/plugin-1.7.0.jar", "sha512": "1fd33596f12f7f2e30e0a59db804cc1a65326b711f5bc8eafccaaf6bee495a33971354d58b17173f5a3f8053f2240e34146ca8e02f230b3319f2d22f5225b8fb", "dest": "offline-repository/io/github/jwharm/flatpak-gradle-generator/plugin/1.7.0", "dest-filename": "plugin-1.7.0.jar" }, { "type": "file", "url": "https://plugins.gradle.org/m2/io/github/jwharm/flatpak-gradle-generator/plugin/1.7.0/plugin-1.7.0.module", "sha512": "4b0865f472199aa42925b39bed7e211e67ca935c4a5e6598d46cc5da313e91a2de6f6d0c775c06b6c79e01d46ab4f44d67ad4d1e5201efd9086d1cccba8e8bad", "dest": "offline-repository/io/github/jwharm/flatpak-gradle-generator/plugin/1.7.0", "dest-filename": "plugin-1.7.0.module" }, { "type": "file", "url": "https://plugins.gradle.org/m2/io/github/jwharm/flatpak-gradle-generator/plugin/1.7.0/plugin-1.7.0.pom", "sha512": "ecc20ed5e412a877d83ecf394e55376492535831d3237f348566bed8fcd31154641e74e4a4f5e841171069be83ef8f53e18dcddaffef3c8847b8ae2a75dd0a49", "dest": "offline-repository/io/github/jwharm/flatpak-gradle-generator/plugin/1.7.0", "dest-filename": "plugin-1.7.0.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/io/grpc/grpc-api/1.69.1/grpc-api-1.69.1.jar", "sha512": "e3cfa77db26b4fc45295b06a2b7636244cef02abd18f5131136c73fe276ea57acddd03fe426fe943d2e6cbb075a102aeed54ae411a6199c16ddeb7e171d0866e", "dest": "offline-repository/io/grpc/grpc-api/1.69.1", "dest-filename": "grpc-api-1.69.1.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/io/grpc/grpc-api/1.69.1/grpc-api-1.69.1.pom", "sha512": "cc5f8e93d88372cb87d4a5aca1429df0e87986ac86fad758d4ef73d67cf883e9046ae429e8a398bc18cf6c9f27996aec066d7a80579ed6d2b392bfe157ccd1ab", "dest": "offline-repository/io/grpc/grpc-api/1.69.1", "dest-filename": "grpc-api-1.69.1.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/io/grpc/grpc-context/1.69.1/grpc-context-1.69.1.jar", "sha512": "72dde2a9a83aaccd73b92931d80916c222578ac710f597a967f4041ebc4d1a6d6979cf773a9404729ca8f200672b77d76523c523f2ed50e0f39f5f1e15e3a74f", "dest": "offline-repository/io/grpc/grpc-context/1.69.1", "dest-filename": "grpc-context-1.69.1.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/io/grpc/grpc-context/1.69.1/grpc-context-1.69.1.pom", "sha512": "f0a2b953383088d3c7b4a8b1e89cb29e9551608a742b78c6c2de8751fa292d5c050e7277e08222a2a7735d31b282bf58cfc06778c87eca10ee8ba2a186491efe", "dest": "offline-repository/io/grpc/grpc-context/1.69.1", "dest-filename": "grpc-context-1.69.1.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/io/grpc/grpc-core/1.69.1/grpc-core-1.69.1.jar", "sha512": "e9fcf546decd203e536848083dcabd60cf0c930e3bb63245e615d2b30fc72aeb8ace9676df01f9ad9c88a1b40098021df4ce6f26e64658ec55fdf1dce671aa6b", "dest": "offline-repository/io/grpc/grpc-core/1.69.1", "dest-filename": "grpc-core-1.69.1.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/io/grpc/grpc-core/1.69.1/grpc-core-1.69.1.pom", "sha512": "921ddda33077e5310162f33b2864a90438c77ecf33f4d0ac5ac9f2d75ca7e8b1f29c5fa60f8b27944977bc27e2000dfc3f74c57174884d9c18329e99f51f80f4", "dest": "offline-repository/io/grpc/grpc-core/1.69.1", "dest-filename": "grpc-core-1.69.1.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/io/grpc/grpc-inprocess/1.69.1/grpc-inprocess-1.69.1.jar", "sha512": "dd625d8132229b7dbc677057921017ce2578bf11aff60201f9be4435dc78459651fa8ffd1d7688ab876f1742b521b73acd83ecb35d4b23c3ca68c10a2e37d2c0", "dest": "offline-repository/io/grpc/grpc-inprocess/1.69.1", "dest-filename": "grpc-inprocess-1.69.1.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/io/grpc/grpc-inprocess/1.69.1/grpc-inprocess-1.69.1.pom", "sha512": "d5f1ff28a9238a3921a0163c33131fb1aabd1b6f81c027042d21fbdf24bf5c3c53de9420c609b5311e931290acd6f81880eaa82a9ce83d4f10ade94ddd242d8d", "dest": "offline-repository/io/grpc/grpc-inprocess/1.69.1", "dest-filename": "grpc-inprocess-1.69.1.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/io/grpc/grpc-netty/1.69.1/grpc-netty-1.69.1.jar", "sha512": "bd87abb67c5a6ecc36625e7d3cd2e3a68e115846464fe43801a7dcd65c0040c2372863e743bf19fa8a112731ebaf1450aedc11050a36fc41b55d22dc0d4b503c", "dest": "offline-repository/io/grpc/grpc-netty/1.69.1", "dest-filename": "grpc-netty-1.69.1.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/io/grpc/grpc-netty/1.69.1/grpc-netty-1.69.1.pom", "sha512": "30e46fa896078aa1da5d9874d210da42b50140c6f701b4850c06166bcc8326fb2ae1ea9e303191c542b96b1e93bbf9ab7af0d2baed8a22fdfc10e6b55c1e2704", "dest": "offline-repository/io/grpc/grpc-netty/1.69.1", "dest-filename": "grpc-netty-1.69.1.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/io/grpc/grpc-protobuf-lite/1.69.1/grpc-protobuf-lite-1.69.1.jar", "sha512": "64a10975dc589221f207cea8d31893b2e7318db91fe4f391b66521c004747c47e72b96e2485ff0d293d28465a09316481efe364f6186b99edb7729d7a34fcb07", "dest": "offline-repository/io/grpc/grpc-protobuf-lite/1.69.1", "dest-filename": "grpc-protobuf-lite-1.69.1.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/io/grpc/grpc-protobuf-lite/1.69.1/grpc-protobuf-lite-1.69.1.pom", "sha512": "fca5aeacd598ac36eaeea0a46ecc67bde5feccd7389d15f32f7c573dc008d102d19932e6c9a1c275154b8928b27ada4299d27bfc0445af3667ed682da470cd99", "dest": "offline-repository/io/grpc/grpc-protobuf-lite/1.69.1", "dest-filename": "grpc-protobuf-lite-1.69.1.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/io/grpc/grpc-protobuf/1.69.1/grpc-protobuf-1.69.1.jar", "sha512": "6d81e633179b5acc71229802a0de0f7072cf5639281305d39b7b7409f1945be3f7f93cd014b6a62b9ab77d1d9610f242a8bcda3499494743913ac79bb42e43d1", "dest": "offline-repository/io/grpc/grpc-protobuf/1.69.1", "dest-filename": "grpc-protobuf-1.69.1.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/io/grpc/grpc-protobuf/1.69.1/grpc-protobuf-1.69.1.pom", "sha512": "04b7e993b0a75427bf6dbbe64952aff9694f0300fdceb130297b2a52f111c56475390f887916454a4833abbec4809f5c6b0c5ba6117df6d1ee301f0e0db5afe0", "dest": "offline-repository/io/grpc/grpc-protobuf/1.69.1", "dest-filename": "grpc-protobuf-1.69.1.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/io/grpc/grpc-stub/1.69.1/grpc-stub-1.69.1.jar", "sha512": "6c62369eec7d9f89e18554c6de5993d6db31f44d44df6f6ab88085fc7fbebe415e06402726ec73f628a992145ea8336df93daa1df434b2d5bcb2f25ea9d94486", "dest": "offline-repository/io/grpc/grpc-stub/1.69.1", "dest-filename": "grpc-stub-1.69.1.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/io/grpc/grpc-stub/1.69.1/grpc-stub-1.69.1.pom", "sha512": "77ebb20dd3433aaed7387cee2ad646c2652f1acc8a43010dd7bd0a3c7cf2283ba97032179fc7740ba5ddddb5c9b464b1638351fd31b4c74e870a0bde6a88f755", "dest": "offline-repository/io/grpc/grpc-stub/1.69.1", "dest-filename": "grpc-stub-1.69.1.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/io/grpc/grpc-util/1.69.1/grpc-util-1.69.1.jar", "sha512": "1fb57e62b0f61d583acdf524279df65ca8f25f774384926f5b5b11e5796d82852e30fea5869c973e0f1617957aecf17fef18c9ce0ece547c47bd19e3a6595341", "dest": "offline-repository/io/grpc/grpc-util/1.69.1", "dest-filename": "grpc-util-1.69.1.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/io/grpc/grpc-util/1.69.1/grpc-util-1.69.1.pom", "sha512": "726d9f8439fb2296ab05e086e267a9c38e8c18bea8fcc55b20961e6edc5a660aafcf446c3fd7ab2bc92af34febcb55f1ae3c880b1b64ad9c1f3bce7a932aa5ac", "dest": "offline-repository/io/grpc/grpc-util/1.69.1", "dest-filename": "grpc-util-1.69.1.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/io/netty/netty-buffer/4.1.110.Final/netty-buffer-4.1.110.Final.jar", "sha512": "4c7c3fde964bf295cd31130e6b0e791f6791042cc526c83fcf2be5376de06c294fcbf49d3199907866e5e8ad27ff873deb66c294b16057135a161f43cf5e617e", "dest": "offline-repository/io/netty/netty-buffer/4.1.110.Final", "dest-filename": "netty-buffer-4.1.110.Final.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/io/netty/netty-buffer/4.1.110.Final/netty-buffer-4.1.110.Final.pom", "sha512": "e3982d621b82a82e2d87ba3dd781a7b062f2f447d1d651fa82252a7d1ff63421fe2468e0cfc7ce5930faf8d6f8401e986b550fdb3e6ec40a2fbee097388185e9", "dest": "offline-repository/io/netty/netty-buffer/4.1.110.Final", "dest-filename": "netty-buffer-4.1.110.Final.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/io/netty/netty-codec-http/4.1.110.Final/netty-codec-http-4.1.110.Final.jar", "sha512": "df5f913a4dca7607aacc420ac67c0727abbd2b54b195876e53dfa90994c1c64b18df1cb08d74de716ed998e9b81de5f872fc68d75632676720ac527921b1a3c0", "dest": "offline-repository/io/netty/netty-codec-http/4.1.110.Final", "dest-filename": "netty-codec-http-4.1.110.Final.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/io/netty/netty-codec-http/4.1.110.Final/netty-codec-http-4.1.110.Final.pom", "sha512": "6f8c1eb86999fe03758afe42a37bc0ab024ef7d7348f61c0fa56b4f4cf1f1d3342af7d0e37052095b0e352524be016d5e77dd770c2b298d33dadedce9bd729db", "dest": "offline-repository/io/netty/netty-codec-http/4.1.110.Final", "dest-filename": "netty-codec-http-4.1.110.Final.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/io/netty/netty-codec-http2/4.1.110.Final/netty-codec-http2-4.1.110.Final.jar", "sha512": "04b33db9e923d4a70675106aafcb3f2f840b403091c1864ad9f00a2325ff6eb47c19cf2143e64506bb750a4704e646bcb23181a849198f00a34347696a5a9978", "dest": "offline-repository/io/netty/netty-codec-http2/4.1.110.Final", "dest-filename": "netty-codec-http2-4.1.110.Final.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/io/netty/netty-codec-http2/4.1.110.Final/netty-codec-http2-4.1.110.Final.pom", "sha512": "259c577997e2b6b5a8cd8667b2f613b6eb81164aaab22647b01a98efbf8309cd53140382c5f5a7143661decabe8d0a01e6e4d10ad1f8b78d12aea0cce71c765a", "dest": "offline-repository/io/netty/netty-codec-http2/4.1.110.Final", "dest-filename": "netty-codec-http2-4.1.110.Final.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/io/netty/netty-codec-socks/4.1.110.Final/netty-codec-socks-4.1.110.Final.jar", "sha512": "3976983153b45d11f1843adfd58ddff70dd36591754207639786e83666809d6d97a1ec5b5729b80cbf2c7d1f3950be575eb63b9fc90ea6acb88b940ce99abb0a", "dest": "offline-repository/io/netty/netty-codec-socks/4.1.110.Final", "dest-filename": "netty-codec-socks-4.1.110.Final.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/io/netty/netty-codec-socks/4.1.110.Final/netty-codec-socks-4.1.110.Final.pom", "sha512": "2e9e341655542b901741e0555c14db6a44c78cba1ef317c751678c9a6d54db17a2beb6a41fdd8d274571a83cae7d8db141866ac59691138faaeb0286c95b58b0", "dest": "offline-repository/io/netty/netty-codec-socks/4.1.110.Final", "dest-filename": "netty-codec-socks-4.1.110.Final.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/io/netty/netty-codec/4.1.110.Final/netty-codec-4.1.110.Final.jar", "sha512": "4ee4546f1a14e13c9cfc9dd489023a4cd6d427431f5ee36884ad30f8e1ae7ab50fffdeef821db09998fd53d2a0985cdc8060de6e0296e166e23752381e09b763", "dest": "offline-repository/io/netty/netty-codec/4.1.110.Final", "dest-filename": "netty-codec-4.1.110.Final.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/io/netty/netty-codec/4.1.110.Final/netty-codec-4.1.110.Final.pom", "sha512": "7167051b8a065501408969eaa469c1fd6cee6b76281b03d49e2e8d99326952f6be3091c964f26482a3216dc022ff7f25bb362acc0e138104cb273fae0222b229", "dest": "offline-repository/io/netty/netty-codec/4.1.110.Final", "dest-filename": "netty-codec-4.1.110.Final.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/io/netty/netty-common/4.1.110.Final/netty-common-4.1.110.Final.jar", "sha512": "5c09ddc68f497a749e0d318e3cec216e18d1f380ad67fa70d55ce109ba552cd3cd02a4717535cef1567f8502dfe1a8f1772e0583f8bc526bea4cfc18fd5fe5dd", "dest": "offline-repository/io/netty/netty-common/4.1.110.Final", "dest-filename": "netty-common-4.1.110.Final.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/io/netty/netty-common/4.1.110.Final/netty-common-4.1.110.Final.pom", "sha512": "5be38b05f7704cb1096295ef78eef31a2628a7a0de300498e9a07b28fbcfb53597bbd2e7405dfe3cd81eff0684feab1113ce18c4813c53d28ae6f2b31921ccd4", "dest": "offline-repository/io/netty/netty-common/4.1.110.Final", "dest-filename": "netty-common-4.1.110.Final.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/io/netty/netty-handler-proxy/4.1.110.Final/netty-handler-proxy-4.1.110.Final.jar", "sha512": "81bb2568ba68f47c6b73408bbf803b8cb3a8e67d577090b8e302d98e3a87bba7c7b8c67ae41522856d9d55493b53df7d847cc5fe22feec48f44d4fd25fa28ffd", "dest": "offline-repository/io/netty/netty-handler-proxy/4.1.110.Final", "dest-filename": "netty-handler-proxy-4.1.110.Final.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/io/netty/netty-handler-proxy/4.1.110.Final/netty-handler-proxy-4.1.110.Final.pom", "sha512": "e63c654efdb7d4e08b0abb04fba0e6174790407af39530b98b269e3c71d526d7dfaa00a3e7a85b2216275111d04ab75fd49f8b6ec0f7792050de3d3b9c31c202", "dest": "offline-repository/io/netty/netty-handler-proxy/4.1.110.Final", "dest-filename": "netty-handler-proxy-4.1.110.Final.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/io/netty/netty-handler/4.1.110.Final/netty-handler-4.1.110.Final.jar", "sha512": "6478df6f5de8337d39de373b578c69b39f31aaa04e3ed018fa26ef3b7fb4cc6a53d7a419e64ad73124b05149b7001dc9025838f305d3158518d406876aaee72b", "dest": "offline-repository/io/netty/netty-handler/4.1.110.Final", "dest-filename": "netty-handler-4.1.110.Final.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/io/netty/netty-handler/4.1.110.Final/netty-handler-4.1.110.Final.pom", "sha512": "f9381fa1594fb86394a94f932f24d1d99659055a2726dab299a389096fa6c2b3f030f3835b780af20b5144c824603badbb35501df59ac5025562b75830cd414d", "dest": "offline-repository/io/netty/netty-handler/4.1.110.Final", "dest-filename": "netty-handler-4.1.110.Final.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/io/netty/netty-parent/4.1.110.Final/netty-parent-4.1.110.Final.pom", "sha512": "df94ab612043f8dd6775cfb2a1e4bb50da2dd61aaecfa29fb92469ab4322279c71981ff4924ea48533aa8948a8f186413525313e1216fefad13865059b648c10", "dest": "offline-repository/io/netty/netty-parent/4.1.110.Final", "dest-filename": "netty-parent-4.1.110.Final.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/io/netty/netty-resolver/4.1.110.Final/netty-resolver-4.1.110.Final.jar", "sha512": "a4d4d072b07895f834d2ac1f8e514f5d7811798303c349d2941b4a938aaebe99281cf6b089efe074ca43aff3f4cced8b11304abe93e94dc061916537d6030635", "dest": "offline-repository/io/netty/netty-resolver/4.1.110.Final", "dest-filename": "netty-resolver-4.1.110.Final.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/io/netty/netty-resolver/4.1.110.Final/netty-resolver-4.1.110.Final.pom", "sha512": "bd16a422870ac0b051e8b6f35c94ebc7d79100a858b4c3827dea52015b3399e47046484d751e4e324d2d11b531dd9a753e5acfc8e2f9c3ddf79aca667426a8b2", "dest": "offline-repository/io/netty/netty-resolver/4.1.110.Final", "dest-filename": "netty-resolver-4.1.110.Final.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/io/netty/netty-transport-native-unix-common/4.1.110.Final/netty-transport-native-unix-common-4.1.110.Final.jar", "sha512": "701c3cbc6230eade231538eb7df627d0c411b41b1e15a55c90159e2ab3271a2c7cb09f36be8932d92179940e79f5944282838d475d50ae083a4078247bb9518e", "dest": "offline-repository/io/netty/netty-transport-native-unix-common/4.1.110.Final", "dest-filename": "netty-transport-native-unix-common-4.1.110.Final.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/io/netty/netty-transport-native-unix-common/4.1.110.Final/netty-transport-native-unix-common-4.1.110.Final.pom", "sha512": "0e3e0664a8e2bb5daf8b7036ce7a1dc0a81bb42bd6aea2fef880eaf3186c05e7553f78d5e886095f7f36af63f5e41abe2a2d829e6b9d1527458ea533fd0d93f7", "dest": "offline-repository/io/netty/netty-transport-native-unix-common/4.1.110.Final", "dest-filename": "netty-transport-native-unix-common-4.1.110.Final.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/io/netty/netty-transport/4.1.110.Final/netty-transport-4.1.110.Final.jar", "sha512": "942af5e0877ad0c536cb5078ac1618176e2a9cf6f7195128a9540d235decc64c668ac276d98571e63d0ea72489bb136721efc00eda2c74be093f4fc04e76baa3", "dest": "offline-repository/io/netty/netty-transport/4.1.110.Final", "dest-filename": "netty-transport-4.1.110.Final.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/io/netty/netty-transport/4.1.110.Final/netty-transport-4.1.110.Final.pom", "sha512": "6c9be003ee48761dde21c60063fd1a06c257db1fcabc38b5c63c2f6e3d16d055e70f05e5ae5bf8811307c0cbc6679179a17a2c9b09704c12b6e401ddf243df1a", "dest": "offline-repository/io/netty/netty-transport/4.1.110.Final", "dest-filename": "netty-transport-4.1.110.Final.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/io/perfmark/perfmark-api/0.27.0/perfmark-api-0.27.0.jar", "sha512": "647b32d75928e44fe355c7f34696d886bd332aa76a6c457e5c90a57d28a8a00e04a5206cf9bba86037660b81eb3f5f82dee7a0dd61330be94f5a71313cf99fd3", "dest": "offline-repository/io/perfmark/perfmark-api/0.27.0", "dest-filename": "perfmark-api-0.27.0.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/io/perfmark/perfmark-api/0.27.0/perfmark-api-0.27.0.module", "sha512": "7e4a85333395d27519512bfb2cf70fdeebcc1648bdc6b5b2de6eef5e52e14b2b97f3a69798b479f7721e8b72aec2f20f7d4b55b6eca9e316a547c4b12516e106", "dest": "offline-repository/io/perfmark/perfmark-api/0.27.0", "dest-filename": "perfmark-api-0.27.0.module" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/io/perfmark/perfmark-api/0.27.0/perfmark-api-0.27.0.pom", "sha512": "2ec342990091973577ee9bdb2ee61357fcf7051233467ccc6696c9aa0c53b82ee4777c74239eec39b6d5048604b1c5974950b9c6aa1464cc6ecd6457cc764206", "dest": "offline-repository/io/perfmark/perfmark-api/0.27.0", "dest-filename": "perfmark-api-0.27.0.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/jakarta/activation/jakarta.activation-api/1.2.1/jakarta.activation-api-1.2.1.jar", "sha512": "c60edc99f119b9e0df0cf527e2512f2b7ab9db0e17c54e83850695f80f652c981eaae90a296db671cf7ed88a044c150224e030df333feb30f346e8a31fb794c6", "dest": "offline-repository/jakarta/activation/jakarta.activation-api/1.2.1", "dest-filename": "jakarta.activation-api-1.2.1.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/jakarta/activation/jakarta.activation-api/1.2.1/jakarta.activation-api-1.2.1.pom", "sha512": "a8af00c78391a59929b6aed1743e50650cbab1f2c9f801757fe6fa3780cc0926489092cac834121260c1c3c702f4ffb4ecacd786ad79d7d38e828e6c39bd399e", "dest": "offline-repository/jakarta/activation/jakarta.activation-api/1.2.1", "dest-filename": "jakarta.activation-api-1.2.1.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/jakarta/xml/bind/jakarta.xml.bind-api-parent/2.3.2/jakarta.xml.bind-api-parent-2.3.2.pom", "sha512": "e0a4baa45935dde64bfd9305cf01ae12bb018844546824df5e4f735924eda55c55e2343616ac92de80deb1c40bf2debe8ee5257a96d11ac4879156ba8d1b6f2c", "dest": "offline-repository/jakarta/xml/bind/jakarta.xml.bind-api-parent/2.3.2", "dest-filename": "jakarta.xml.bind-api-parent-2.3.2.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/jakarta/xml/bind/jakarta.xml.bind-api/2.3.2/jakarta.xml.bind-api-2.3.2.jar", "sha512": "5a9a94fc323aecc9c5b28e9ac688aac8d09725d4cae660a57f5698914db91e351283dfe4909a2cc6de803890ac2b3b9f06af9d071d465031e55326a1085a11db", "dest": "offline-repository/jakarta/xml/bind/jakarta.xml.bind-api/2.3.2", "dest-filename": "jakarta.xml.bind-api-2.3.2.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/jakarta/xml/bind/jakarta.xml.bind-api/2.3.2/jakarta.xml.bind-api-2.3.2.pom", "sha512": "7b144c06ac7e7ddf63596406d3b441e3e5a3ba7b408a659bc0ceb5b528936b944108398bc06298054de09228293ab3feed01e4e16be493ecc5fb9780d8c52379", "dest": "offline-repository/jakarta/xml/bind/jakarta.xml.bind-api/2.3.2", "dest-filename": "jakarta.xml.bind-api-2.3.2.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/javax/annotation/javax.annotation-api/1.3.2/javax.annotation-api-1.3.2.jar", "sha512": "679cf44c3b9d635b43ed122a555d570292c3f0937c33871c40438a1a53e2058c80578694ec9466eac9e280e19bfb7a95b261594cc4c1161c85dc97df6235e553", "dest": "offline-repository/javax/annotation/javax.annotation-api/1.3.2", "dest-filename": "javax.annotation-api-1.3.2.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/javax/annotation/javax.annotation-api/1.3.2/javax.annotation-api-1.3.2.pom", "sha512": "b97c6fb3b5c6f9ba5c6ec2aa35713816fca94927c1393d2eaa04cac6a6c44c464baeb34d62d9af33268a7eaa817318df1b2116641ff7e8ade84c70b358e60bac", "dest": "offline-repository/javax/annotation/javax.annotation-api/1.3.2", "dest-filename": "javax.annotation-api-1.3.2.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/javax/inject/javax.inject/1/javax.inject-1.jar", "sha512": "e126b7ccf3e42fd1984a0beef1004a7269a337c202e59e04e8e2af714280d2f2d8d2ba5e6f59481b8dcd34aaf35c966a688d0b48ec7e96f102c274dc0d3b381e", "dest": "offline-repository/javax/inject/javax.inject/1", "dest-filename": "javax.inject-1.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/javax/inject/javax.inject/1/javax.inject-1.pom", "sha512": "02f0c773ba24b74f45f6519c653cb118395f81389c7e73a034f82074a3e277f793d77783d794143236b05fc5247af5f69d9b2605d0929b742a5673a55e51f880", "dest": "offline-repository/javax/inject/javax.inject/1", "dest-filename": "javax.inject-1.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/net/java/dev/jna/jna-platform/5.6.0/jna-platform-5.6.0.jar", "sha512": "e55cb2eb60742d4a1de61e5d85b4c71d1a6769ef793b9cb5454ed1775ed593201e3c833e42ea033c99ee256217fde17bac5b93c8f12cbb90795c14956eb31d61", "dest": "offline-repository/net/java/dev/jna/jna-platform/5.6.0", "dest-filename": "jna-platform-5.6.0.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/net/java/dev/jna/jna-platform/5.6.0/jna-platform-5.6.0.pom", "sha512": "1d1c2f90cb34ef8ac5a6f7cd6e4381aae763a16b81f1e685c0bfe69adf2e7a4b5b80234f731bccf28ff9361a2b337447749230726e088e6784da45b93a9591de", "dest": "offline-repository/net/java/dev/jna/jna-platform/5.6.0", "dest-filename": "jna-platform-5.6.0.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/net/java/dev/jna/jna/5.6.0/jna-5.6.0.jar", "sha512": "f250d92a70ef686466d44592a10513420dc6d6ec188e479f4ceb5ee6615505f3aad2941949364c89f09781b3f8bb09e0679f779ce81c1231f714f9a4f7d769ba", "dest": "offline-repository/net/java/dev/jna/jna/5.6.0", "dest-filename": "jna-5.6.0.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/net/java/dev/jna/jna/5.6.0/jna-5.6.0.pom", "sha512": "b9cf65b293f20d563877d5e1a23292a794e6470c77496f6fe82978bb0f8e13d9402bfe2fedc716ddbce6989383ff90bf65d3ecd2c821b0c95e00f8951e90b306", "dest": "offline-repository/net/java/dev/jna/jna/5.6.0", "dest-filename": "jna-5.6.0.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/net/java/jvnet-parent/1/jvnet-parent-1.pom", "sha512": "22fb9b68f57380088955b5526bfc382da87332202bb4741def44af2ec340240d8d2ad1283ffcab0433175fe35ab5540a503aaf9a2e44aad1de005c8915bfabe8", "dest": "offline-repository/net/java/jvnet-parent/1", "dest-filename": "jvnet-parent-1.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/net/java/jvnet-parent/3/jvnet-parent-3.pom", "sha512": "93b78fac40ca4de12d5a2fb4e339ba9e3c40a25ddcfe58272dc2a8e4b36d2c7cc51075aa2a25f0b3c1d4bd3142551e77847d1bd5599c60f5d50d548b72b74bfa", "dest": "offline-repository/net/java/jvnet-parent/3", "dest-filename": "jvnet-parent-3.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/net/sf/jopt-simple/jopt-simple/4.9/jopt-simple-4.9.jar", "sha512": "fd04c19bce810a1548b2d2eaadb915cff2cbc81a81ec5258aafc1ba329100daedc49edad1fc7b254ab892996796124283d7004b5414f662c0efa3979add9ca5f", "dest": "offline-repository/net/sf/jopt-simple/jopt-simple/4.9", "dest-filename": "jopt-simple-4.9.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/net/sf/jopt-simple/jopt-simple/4.9/jopt-simple-4.9.pom", "sha512": "fc6dbc416babd1d49152b832cfbb425686a718cce2157a96743be2d541e65dc072ed617b6eca8003c3bbb4be65394c5468ca2ec52eceaba4d3baffb10ff8c489", "dest": "offline-repository/net/sf/jopt-simple/jopt-simple/4.9", "dest-filename": "jopt-simple-4.9.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/net/sf/kxml/kxml2/2.3.0/kxml2-2.3.0.jar", "sha512": "f97d418d4c2892fa184f5be83166ac2cd771fd10d7625104d9b054ec0ff361927a2ac2539d38f326f61373b6d700a3b5075605763562ac0ae6714903773cd1cb", "dest": "offline-repository/net/sf/kxml/kxml2/2.3.0", "dest-filename": "kxml2-2.3.0.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/net/sf/kxml/kxml2/2.3.0/kxml2-2.3.0.pom", "sha512": "ed580cade03dd0358b3221ae507804b9fcc15d47470a6b726dbe2920756ab3ae3b7f7b57ba04ad7a631d46c4d70cc88d5e460d0fa34c05e1aa47d222dc7ee872", "dest": "offline-repository/net/sf/kxml/kxml2/2.3.0", "dest-filename": "kxml2-2.3.0.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/apache/apache/13/apache-13.pom", "sha512": "3b25f9f51a7ee9647fe2e1287e75a67ccdf3f08055bec20c6a60b290876afc691f16b23ab3df7b733695b828411b716a0b3509c22ec6fb0c5dce4f21811ae434", "dest": "offline-repository/org/apache/apache/13", "dest-filename": "apache-13.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/apache/apache/18/apache-18.pom", "sha512": "9ef6f99b30fe2603ad8f2c88116072de36bd2dc99590fd9e7eecf153dbf50cbd766694d861e666138d2a26137be69fe98cc38a491f6a2a68e8d421d656731ed1", "dest": "offline-repository/org/apache/apache/18", "dest-filename": "apache-18.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/apache/apache/21/apache-21.pom", "sha512": "c82bd27c06d76b3f467118b1a8e0976c60fd0e7d7a01dac685c9a91e1822c0e6f5829bf48ad532a9fc000089cdb894a97c5686365fd3c8c8bfa787136a4fa9d2", "dest": "offline-repository/org/apache/apache/21", "dest-filename": "apache-21.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/apache/apache/23/apache-23.pom", "sha512": "d17d23bcd3d1cd95d15783aaa6f861e3d0bea60f8a1adf6182b298cdf7ad0a1eb74a775b920bfe8324c414747979623a6a4615d18c492dc25d71e48dd6a504b8", "dest": "offline-repository/org/apache/apache/23", "dest-filename": "apache-23.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/apache/apache/31/apache-31.pom", "sha512": "12563230cf16a646d4a20453e6ae98e9f8eb5abf89502620314c7040f2c006cd0795c7615e02a0f4c6e1d87328b156fa89b83a9990cfcfb2a0e8fc7b7b9f97f9", "dest": "offline-repository/org/apache/apache/31", "dest-filename": "apache-31.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/apache/commons/commons-compress/1.21/commons-compress-1.21.jar", "sha512": "c92d9a12547aab475e057955ad815fdfe92ff44c78383fa5af54b089f1bff5525126ef6aef93334f3bfc22e2fef4ad0d969f69384e978a83a55f011a53e7e471", "dest": "offline-repository/org/apache/commons/commons-compress/1.21", "dest-filename": "commons-compress-1.21.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/apache/commons/commons-compress/1.21/commons-compress-1.21.pom", "sha512": "530a1505ca1e1c4eb9336b7a7cae3116ea9fc81d77d0e2530f1c050a8b5593cd65adc90947f13fb7e10e40db479c54415cc9ad0f58bf5d1f924f3986ed634bfd", "dest": "offline-repository/org/apache/commons/commons-compress/1.21", "dest-filename": "commons-compress-1.21.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/apache/commons/commons-parent/34/commons-parent-34.pom", "sha512": "364ede203a23157ec601d28ff141c0c69759fc5c483e44e346fa1592403f343f0722f7763243b2ee7a190c7a744b1cce1f40247f5a6c7b3dbfbf487c505a40bf", "dest": "offline-repository/org/apache/commons/commons-parent/34", "dest-filename": "commons-parent-34.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/apache/commons/commons-parent/42/commons-parent-42.pom", "sha512": "a35d1f7919551adbeada88d9f47a4d6de200380aa43266f91d4b65255120cbf4b11f73bb3f9b98eeb960fd9f1b0793dd48095b46270ed2376ce3b122861a94d6", "dest": "offline-repository/org/apache/commons/commons-parent/42", "dest-filename": "commons-parent-42.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/apache/commons/commons-parent/52/commons-parent-52.pom", "sha512": "73304055a0bd1da4d0e6f6f9adfe4327f970f40b9703b9df15dd2048b8a617c32a3ee98a95d0f70d3bae719940ed5d072487e4a222d9a9a2c7e5fa15eca0b658", "dest": "offline-repository/org/apache/commons/commons-parent/52", "dest-filename": "commons-parent-52.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/apache/commons/commons-parent/69/commons-parent-69.pom", "sha512": "c7f3f2d929f60251a2afe80d3b53fac48752ca61e668ae56588d23b162cd3c911d6c3da76148a340d19512555152eccfd435b4d9fbe628ce10054bac83ed07e7", "dest": "offline-repository/org/apache/commons/commons-parent/69", "dest-filename": "commons-parent-69.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/apache/httpcomponents/httpclient/4.5.14/httpclient-4.5.14.jar", "sha512": "a084ef30fb0a2a25397d8fab439fe68f67e294bf53153e2e1355b8df92886d40fe6abe35dc84f014245f7158e92641bcbd98019b4fbbd9e5a0db495b160b4ced", "dest": "offline-repository/org/apache/httpcomponents/httpclient/4.5.14", "dest-filename": "httpclient-4.5.14.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/apache/httpcomponents/httpclient/4.5.14/httpclient-4.5.14.pom", "sha512": "46500859b1206c1ec6a69c66d6b6d224ac835c9316a88403a2060c658b4c4e2a7f69ce0b3b5f1900abf479ebda10757c25ab595de9527b53f9e80abdf48383e8", "dest": "offline-repository/org/apache/httpcomponents/httpclient/4.5.14", "dest-filename": "httpclient-4.5.14.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/apache/httpcomponents/httpcomponents-client/4.5.14/httpcomponents-client-4.5.14.pom", "sha512": "7ae6fe0a7865aa7aeb1e4f3f3694856e0b0c5f7d5e452682e3734b45e433d2001352bea46fc32c1694bd10577b16030f77161a0fdca7c369afc2cc5edf4c55e2", "dest": "offline-repository/org/apache/httpcomponents/httpcomponents-client/4.5.14", "dest-filename": "httpcomponents-client-4.5.14.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/apache/httpcomponents/httpcomponents-client/4.5.6/httpcomponents-client-4.5.6.pom", "sha512": "5c4762d49bacb5f2138db3d701bc1d1108d08fea2909d274528600ecbd21de05d39b74009023af7b8552dd43c480dc3eb04229ecc1a4e6535daa4a90526aff49", "dest": "offline-repository/org/apache/httpcomponents/httpcomponents-client/4.5.6", "dest-filename": "httpcomponents-client-4.5.6.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/apache/httpcomponents/httpcomponents-core/4.4.16/httpcomponents-core-4.4.16.pom", "sha512": "7bc3e413442010aeaaa9fa6fd11e4c32aea9cd50ad19f485869d469a13886ca70f645169c1130ccac864d73570b8dde21562313ef3f8c031ffaf8500b60e14d3", "dest": "offline-repository/org/apache/httpcomponents/httpcomponents-core/4.4.16", "dest-filename": "httpcomponents-core-4.4.16.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/apache/httpcomponents/httpcomponents-parent/10/httpcomponents-parent-10.pom", "sha512": "2a13d94b4af958e39d49408842080f097c662cf2276befb89b13b94b95eaba64eeb389b5bc6638170658a00de42104001daa3eb0650fae32ba4cb44503022c71", "dest": "offline-repository/org/apache/httpcomponents/httpcomponents-parent/10", "dest-filename": "httpcomponents-parent-10.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/apache/httpcomponents/httpcomponents-parent/11/httpcomponents-parent-11.pom", "sha512": "bc676698caec72d525b9bf408432e9cd9c7b5d2227e0778fd79c303041cb5b07b88f98433d59c0149d6c11c27c3834722ceb283048919e467785efd7f4c399a2", "dest": "offline-repository/org/apache/httpcomponents/httpcomponents-parent/11", "dest-filename": "httpcomponents-parent-11.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/apache/httpcomponents/httpcore/4.4.16/httpcore-4.4.16.jar", "sha512": "168026436a6bcf5e96c0c59606638abbdc30de4b405ae55afde70fdf2895e267a3d48bba6bdadc5a89f38e31da3d9a9dc91e1cab7ea76f5e04322cf1ec63b838", "dest": "offline-repository/org/apache/httpcomponents/httpcore/4.4.16", "dest-filename": "httpcore-4.4.16.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/apache/httpcomponents/httpcore/4.4.16/httpcore-4.4.16.pom", "sha512": "8d8809246d6a665e0870dc396da4292c13bee7db7d82fdfffd90049b33141d45d5c1b022d095d0f270fa758472dcd3c6ec742efa045e03eedbe66ceeeca426d4", "dest": "offline-repository/org/apache/httpcomponents/httpcore/4.4.16", "dest-filename": "httpcore-4.4.16.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/apache/httpcomponents/httpmime/4.5.6/httpmime-4.5.6.jar", "sha512": "9841db7779b647de4668ded9b79e8c510a653076384fc3059ef186ea5d82828e149de48c016b5cb89a1beabe60981429265a8324be3108473c57af77a62abd6a", "dest": "offline-repository/org/apache/httpcomponents/httpmime/4.5.6", "dest-filename": "httpmime-4.5.6.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/apache/httpcomponents/httpmime/4.5.6/httpmime-4.5.6.pom", "sha512": "4d020fc801b1ca440df4ebe044b0dffae5cea59e9c48205beeceb1931ea3682f11eb7fb36f054693cf9f32886da6b48b727265b60a0ffa49300b220325a09266", "dest": "offline-repository/org/apache/httpcomponents/httpmime/4.5.6", "dest-filename": "httpmime-4.5.6.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/bitbucket/b_c/jose4j/0.9.5/jose4j-0.9.5.jar", "sha512": "d475aa9e74a173e27aa72f4361eef288d259f337f8082eafa572aad763150ea679829e2433cb03cb0249eb58a83d68bfbc54d124154dd71b745fc7182620c59f", "dest": "offline-repository/org/bitbucket/b_c/jose4j/0.9.5", "dest-filename": "jose4j-0.9.5.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/bitbucket/b_c/jose4j/0.9.5/jose4j-0.9.5.pom", "sha512": "b3840f21c533a48a393dc1cf50cdf73cca9779af150cb8d0f51cc5932b59b2ade1a97dffb005d075b6fa3b9d42e26f81a8014fd98243734995c30462930871d6", "dest": "offline-repository/org/bitbucket/b_c/jose4j/0.9.5", "dest-filename": "jose4j-0.9.5.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/bouncycastle/bcpkix-jdk18on/1.79/bcpkix-jdk18on-1.79.jar", "sha512": "12b6b18d6bb89d4c82d616210467fb7c3951d1b6a9dff10b4b7633ec708aabea07a0f39c48344ab18fdfec2975f6ef8911ba2ca9189ff75c522574b6d76f4abc", "dest": "offline-repository/org/bouncycastle/bcpkix-jdk18on/1.79", "dest-filename": "bcpkix-jdk18on-1.79.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/bouncycastle/bcpkix-jdk18on/1.79/bcpkix-jdk18on-1.79.pom", "sha512": "0d3bcb7e7a468cd7b9fddd580a0e30b9cd350eb867396e62004798e983dfdfd42e14db5aa3decf958414b8bfbe5569400784ab2602332ebb20057c7972376f30", "dest": "offline-repository/org/bouncycastle/bcpkix-jdk18on/1.79", "dest-filename": "bcpkix-jdk18on-1.79.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/bouncycastle/bcprov-jdk18on/1.79/bcprov-jdk18on-1.79.jar", "sha512": "27bc54158da8165a55a7edae9fd09980795979cee099e98d81fadb4bd4dce4d5c86b8a635d1e892e286676d23b6ae5961a762fadf3a864513c8e0c6dbbdefd3a", "dest": "offline-repository/org/bouncycastle/bcprov-jdk18on/1.79", "dest-filename": "bcprov-jdk18on-1.79.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/bouncycastle/bcprov-jdk18on/1.79/bcprov-jdk18on-1.79.pom", "sha512": "def89485d4db1be6299d539e261c52bc89830e21256005f26315f81637e0292d37e4ce754fd00dbc8a51e6c65cdd94e5fb5837fbc4d63d4835e90e8e96a7e040", "dest": "offline-repository/org/bouncycastle/bcprov-jdk18on/1.79", "dest-filename": "bcprov-jdk18on-1.79.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/bouncycastle/bcutil-jdk18on/1.79/bcutil-jdk18on-1.79.jar", "sha512": "a3ec7c22f6e716e2c06b9e93b1992bda23eb92ea0cc3f3afc5bd7ae44a9235ff2216d0c0097799a30fd2e2dd618e7f30cc210007da61e1dee2e72b8fbb0de16f", "dest": "offline-repository/org/bouncycastle/bcutil-jdk18on/1.79", "dest-filename": "bcutil-jdk18on-1.79.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/bouncycastle/bcutil-jdk18on/1.79/bcutil-jdk18on-1.79.pom", "sha512": "2deabbe38b38c0733bdecd5a1eee264f251ecedefa551479b2ea2415f5f659b856eb329e2efb265b26313fd8c1082007b1145bfd3df181875b8f4dc861b8f02d", "dest": "offline-repository/org/bouncycastle/bcutil-jdk18on/1.79", "dest-filename": "bcutil-jdk18on-1.79.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/checkerframework/checker-qual/3.43.0/checker-qual-3.43.0.jar", "sha512": "823ea28e3c822ff48e4e985a421fa0b53ca3419e2c0635c3d4d0a9822399b6491780e26a9161d4733857c97987bff2d725ff4453b2c22ed412eef46ea27f5d84", "dest": "offline-repository/org/checkerframework/checker-qual/3.43.0", "dest-filename": "checker-qual-3.43.0.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/checkerframework/checker-qual/3.43.0/checker-qual-3.43.0.module", "sha512": "b5f80edd3661a81288365222b95a85e19096e4bd5485f59b7d72b807553ba8c0dbb1cee300a987f6a0ced3d4114b494cbabe82f52e48f596ae17fab1f1898eba", "dest": "offline-repository/org/checkerframework/checker-qual/3.43.0", "dest-filename": "checker-qual-3.43.0.module" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/checkerframework/checker-qual/3.43.0/checker-qual-3.43.0.pom", "sha512": "b7bdf783e5ea98ee0e11d264f4a0022fd5ae4c228a00e2e8ba2f36c1a3991903ac334ea030bcbf1837e4097599f7cd0a599e0b4c03b35d5dc7550b4d93efda0f", "dest": "offline-repository/org/checkerframework/checker-qual/3.43.0", "dest-filename": "checker-qual-3.43.0.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/codehaus/mojo/animal-sniffer-annotations/1.24/animal-sniffer-annotations-1.24.jar", "sha512": "6f8118093576be9dc0860cd02600d9e159d78e99b94f06c2dcbbfea6cc3a7d509418dd697b998a9abcdf0315497e4ab21e16f3fd2942e97bcf7fc16b1122bcca", "dest": "offline-repository/org/codehaus/mojo/animal-sniffer-annotations/1.24", "dest-filename": "animal-sniffer-annotations-1.24.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/codehaus/mojo/animal-sniffer-annotations/1.24/animal-sniffer-annotations-1.24.pom", "sha512": "dc61cc5f6e937cff5b14c4a5b6cca6ddb4ab4a8a9c1b768ea2b86bf2b225c5d788f14a085cd2681c9a73d1fd7be7f45ce906b1009d31ff6705ae5671c32ce9e2", "dest": "offline-repository/org/codehaus/mojo/animal-sniffer-annotations/1.24", "dest-filename": "animal-sniffer-annotations-1.24.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/codehaus/mojo/animal-sniffer-parent/1.24/animal-sniffer-parent-1.24.pom", "sha512": "b3869abce76c232d11ff8f5e8b6dd86bcc6e809dea7c0ba3c39e04e508d0066ce4eb3c60004287a2d8b1bd3e72733281f5c59f9e002c46de4ffd251e0eafaeff", "dest": "offline-repository/org/codehaus/mojo/animal-sniffer-parent/1.24", "dest-filename": "animal-sniffer-parent-1.24.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/codehaus/mojo/mojo-parent/84/mojo-parent-84.pom", "sha512": "2cb800cf9930fa4229facd2b0eb1d99bc04f02b302acdaa240061cb7266d5b0dfe84782e5889e8c7d15ace2328f0f6c2278e9ff9751ef41ded2b5c660e52d0c9", "dest": "offline-repository/org/codehaus/mojo/mojo-parent/84", "dest-filename": "mojo-parent-84.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/eclipse/ee4j/project/1.0.2/project-1.0.2.pom", "sha512": "0725e91db9dee43a75ba70ec073da98dbe158b157f6038b9a5fc906e4307add423b213829bea25944f42c88a37417788398c5136267ce8005a530505c5b5fa28", "dest": "offline-repository/org/eclipse/ee4j/project/1.0.2", "dest-filename": "project-1.0.2.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/eclipse/ee4j/project/1.0.5/project-1.0.5.pom", "sha512": "5fd90f300231200c1158602372fa9b6ae2cf2746200c7c98e7dcb8639ece995d44c54817cc44c5ad40c6b18e311bee771933550acbb55954fa25eaa9b3140dea", "dest": "offline-repository/org/eclipse/ee4j/project/1.0.5", "dest-filename": "project-1.0.5.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/glassfish/jaxb/jaxb-bom/2.3.2/jaxb-bom-2.3.2.pom", "sha512": "91a18e46630179e6637e5c07bb156eec873dfcdf5676a0c51ec14bb8687e961a7e01c56a091c6502c85e1bf1e257bbf6c130e93e74bb364a3f41efba7c7a40da", "dest": "offline-repository/org/glassfish/jaxb/jaxb-bom/2.3.2", "dest-filename": "jaxb-bom-2.3.2.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/glassfish/jaxb/jaxb-runtime/2.3.2/jaxb-runtime-2.3.2.jar", "sha512": "8a6cf3dd9fac4fb6ddb5f0861a2cac093e04d186fc787ee78c30862d225eb677b9605e1177cf57da0d4f45c613f107187c330be0684b43b0cab9a922cd96db66", "dest": "offline-repository/org/glassfish/jaxb/jaxb-runtime/2.3.2", "dest-filename": "jaxb-runtime-2.3.2.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/glassfish/jaxb/jaxb-runtime/2.3.2/jaxb-runtime-2.3.2.pom", "sha512": "4827043a65d75575a8110a8bd7baf78a7a36b05f21facdb948c5a9474793cd3e1d67c98c9b571544e0c16701872222671906880e7b70639f3bb41bbaa387b7f9", "dest": "offline-repository/org/glassfish/jaxb/jaxb-runtime/2.3.2", "dest-filename": "jaxb-runtime-2.3.2.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/glassfish/jaxb/txw2/2.3.2/txw2-2.3.2.jar", "sha512": "66c174093c47b75b900159c5449bd99eb15308d15b395771367adc5862612edb985c3678428c4f60e7c27b46d123afac39277f80db766511a6b6f0756d525559", "dest": "offline-repository/org/glassfish/jaxb/txw2/2.3.2", "dest-filename": "txw2-2.3.2.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/glassfish/jaxb/txw2/2.3.2/txw2-2.3.2.pom", "sha512": "8fbd16b2d9321d5fa9e9cb5e15e0aeaf1ad15f8147226d51ef9cd786f456bf0ae7fdb66742c8bb58226eb54dcc634443d6635924cb9f0b2b5082faaa807ff356", "dest": "offline-repository/org/glassfish/jaxb/txw2/2.3.2", "dest-filename": "txw2-2.3.2.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jdom/jdom2/2.0.6/jdom2-2.0.6.jar", "sha512": "315791dc16bc6240d81da7fee9ae325102ff7db19a57805335d189bc747abc4d1c80144589ebf956613b93b2263c7565fdf171aca0c6c598616eb3f0bdf4cc58", "dest": "offline-repository/org/jdom/jdom2/2.0.6", "dest-filename": "jdom2-2.0.6.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jdom/jdom2/2.0.6/jdom2-2.0.6.pom", "sha512": "1c111e5c52440b00b5573e05a9f08c048d5630b1d77d4dbcc8b9efe8b68584d7926d8b4c867ef6b212ecc5cf549360eb8ecd654f96f04c2a354aa52e493bce93", "dest": "offline-repository/org/jdom/jdom2/2.0.6", "dest-filename": "jdom2-2.0.6.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/annotations/13.0/annotations-13.0.jar", "sha512": "5622d0ffe410e7272e2bb9fae1006caedeb86d0c62d2d9f3929a3b3cdcdef1963218fcf0cede82e95ef9f4da3ed4a173fa055ee6e4038886376181e0423e02ff", "dest": "offline-repository/org/jetbrains/annotations/13.0", "dest-filename": "annotations-13.0.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/annotations/13.0/annotations-13.0.pom", "sha512": "63ef480f698215d4cd4501b06e86df1a741ac2b86216fd3ff6eee146da746caa390df27351e25598971edb368aeae41055ff1ed77e4bf5d7edb6abc832d150ce", "dest": "offline-repository/org/jetbrains/annotations/13.0", "dest-filename": "annotations-13.0.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/compose/compose-gradle-plugin/1.10.1/compose-gradle-plugin-1.10.1.jar", "sha512": "00921bc9e3a7cffc435b54746a32e34f607ab7152963b19d4bf4b0c47f9eb81a0353efcbf11fdcb3660846dd0cd4414bce800a3dfc37110215f466284c6a32a4", "dest": "offline-repository/org/jetbrains/compose/compose-gradle-plugin/1.10.1", "dest-filename": "compose-gradle-plugin-1.10.1.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/compose/compose-gradle-plugin/1.10.1/compose-gradle-plugin-1.10.1.module", "sha512": "a2f4ca3d03ddb5b0622da73a4f118307ad9941ef05a6922f1bdb5c6fb56348a2f1574b8f826a555d05fc1a7a7a3905f5e350af54ef3b560bff77ba78f9b43eff", "dest": "offline-repository/org/jetbrains/compose/compose-gradle-plugin/1.10.1", "dest-filename": "compose-gradle-plugin-1.10.1.module" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/compose/compose-gradle-plugin/1.10.1/compose-gradle-plugin-1.10.1.pom", "sha512": "fc006a917da0a9f992c784e022b18158318a072d1f8120975f70b4de171b9046f830f82a6019183c4fc92384d42ccd8f6c322e491f79d6c47622302530b7bd4d", "dest": "offline-repository/org/jetbrains/compose/compose-gradle-plugin/1.10.1", "dest-filename": "compose-gradle-plugin-1.10.1.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/compose/hot-reload/hot-reload-gradle-plugin/1.0.0/hot-reload-gradle-plugin-1.0.0.jar", "sha512": "7570a9ed3190047e0e4f85069edb223cfaf1402e5ec97afe0c7eb29daff5336518771bfd22b5d345bc1c3133cb2b377ea0965073542ca724582b4bc56e478d52", "dest": "offline-repository/org/jetbrains/compose/hot-reload/hot-reload-gradle-plugin/1.0.0", "dest-filename": "hot-reload-gradle-plugin-1.0.0.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/compose/hot-reload/hot-reload-gradle-plugin/1.0.0/hot-reload-gradle-plugin-1.0.0.module", "sha512": "79fe305d0973905db843a6536c9cb196de81521b82d704c614bef317c55b262a1e426eb26a09fec399c440699222c0cdd743a314a8ffacd7a9beb7503b1acccf", "dest": "offline-repository/org/jetbrains/compose/hot-reload/hot-reload-gradle-plugin/1.0.0", "dest-filename": "hot-reload-gradle-plugin-1.0.0.module" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/compose/hot-reload/hot-reload-gradle-plugin/1.0.0/hot-reload-gradle-plugin-1.0.0.pom", "sha512": "fcb859685dc40ab549173dbcba69ef6f967f255b3151ee1b1fc7e688d4b4d736885286fb1beaee7d95f0a717de29580e452a8f4f33cb3e826e9c334cfa848155", "dest": "offline-repository/org/jetbrains/compose/hot-reload/hot-reload-gradle-plugin/1.0.0", "dest-filename": "hot-reload-gradle-plugin-1.0.0.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/compose/hot-reload/org.jetbrains.compose.hot-reload.gradle.plugin/1.0.0/org.jetbrains.compose.hot-reload.gradle.plugin-1.0.0.pom", "sha512": "8e9dd6e31425c5e2036365e9e2a396117143b63f4daf01573e76c3deea564825e7165facc405360d4b09b25a1ae3fec961f359e4d20fd5c826f399277f9b519b", "dest": "offline-repository/org/jetbrains/compose/hot-reload/org.jetbrains.compose.hot-reload.gradle.plugin/1.0.0", "dest-filename": "org.jetbrains.compose.hot-reload.gradle.plugin-1.0.0.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/compose/org.jetbrains.compose.gradle.plugin/1.10.1/org.jetbrains.compose.gradle.plugin-1.10.1.pom", "sha512": "fd3021f97362ec034f0c0a3de69e358c7a66e4b9f967acc57e030440bce26a21dd5ccfcaea7f2911440477da173f0db6ca9dbecfe31efd6c622d0ea10b6b260d", "dest": "offline-repository/org/jetbrains/compose/org.jetbrains.compose.gradle.plugin/1.10.1", "dest-filename": "org.jetbrains.compose.gradle.plugin-1.10.1.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/abi-tools-api/2.3.10/abi-tools-api-2.3.10.jar", "sha512": "201ca364021448c2918df71d30089df589a4e3ebe56c3ad8d09014ed672ab7a16dfe1c5ed3362d8ef885dee1a0cf06e188621d1ae9c932ced974a129f30aa18d", "dest": "offline-repository/org/jetbrains/kotlin/abi-tools-api/2.3.10", "dest-filename": "abi-tools-api-2.3.10.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/abi-tools-api/2.3.10/abi-tools-api-2.3.10.pom", "sha512": "d5f411fa775ec484f75484cf7baae80f0de0e4d3a03328b468474c908305ba25dd035aadbe24dd7bff024d7283fb041b15689d8ba69bd30dedd70829c500570d", "dest": "offline-repository/org/jetbrains/kotlin/abi-tools-api/2.3.10", "dest-filename": "abi-tools-api-2.3.10.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/atomicfu/2.3.10/atomicfu-2.3.10.pom", "sha512": "3b8a532071834d576e704368136a40297b3dbcdfc7db025652ed378a2552af5e37539e508f8a42e00d7c2be6f450b2e45d75400a9111680610840c08c66ff45a", "dest": "offline-repository/org/jetbrains/kotlin/atomicfu/2.3.10", "dest-filename": "atomicfu-2.3.10.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/compose-compiler-gradle-plugin/2.3.10/compose-compiler-gradle-plugin-2.3.10-gradle813.jar", "sha512": "37bc5b39022addd7e832504946b93f7c524908a8b32aa62db7684c97c2bb060039cc7547e762bfd925b50891c339d63fcca0b68cb2a5c9aa8532378f10b0efe5", "dest": "offline-repository/org/jetbrains/kotlin/compose-compiler-gradle-plugin/2.3.10", "dest-filename": "compose-compiler-gradle-plugin-2.3.10-gradle813.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/compose-compiler-gradle-plugin/2.3.10/compose-compiler-gradle-plugin-2.3.10.module", "sha512": "7f35ced92fd9b1640dedcb617b1c65c0d107867879cb349c738684f7665a33a9a707c9c68d24919315c30a68ebf183e84f9acc1c2035c86d8b50e7b882691dba", "dest": "offline-repository/org/jetbrains/kotlin/compose-compiler-gradle-plugin/2.3.10", "dest-filename": "compose-compiler-gradle-plugin-2.3.10.module" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/compose-compiler-gradle-plugin/2.3.10/compose-compiler-gradle-plugin-2.3.10.pom", "sha512": "46ebaf0d0277e1f8f041c2449ff89899376eb660095d7c86539fa20defe512ff6b5dba9ad1cf1b71b6e4a877e8b2900af26c5d95bd08002861fff181c1da23ee", "dest": "offline-repository/org/jetbrains/kotlin/compose-compiler-gradle-plugin/2.3.10", "dest-filename": "compose-compiler-gradle-plugin-2.3.10.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/fus-statistics-gradle-plugin/2.3.10/fus-statistics-gradle-plugin-2.3.10-gradle813.jar", "sha512": "60aa5060e364d1f936c925d6ec30d0f685359f84fe37001b6c42f2a47de6804627526774ebb9f3124cdda67ff1867fa2acf4d5da6fd44cc69bb26c155f5069b2", "dest": "offline-repository/org/jetbrains/kotlin/fus-statistics-gradle-plugin/2.3.10", "dest-filename": "fus-statistics-gradle-plugin-2.3.10-gradle813.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/fus-statistics-gradle-plugin/2.3.10/fus-statistics-gradle-plugin-2.3.10.module", "sha512": "5fb4d41cceafebb14c80adc458f8b08c37fb9d7ceab2e19620342e262d38a123f0b861ca8015f53f2a606fb83ce8f1c41bc8437e0b1956ade2de2f2491215b12", "dest": "offline-repository/org/jetbrains/kotlin/fus-statistics-gradle-plugin/2.3.10", "dest-filename": "fus-statistics-gradle-plugin-2.3.10.module" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/fus-statistics-gradle-plugin/2.3.10/fus-statistics-gradle-plugin-2.3.10.pom", "sha512": "8eeb13dfb218d9d739186a5ba8152bd2b044a6b23e297d040b73db602b0fc03ec725222464c270b7707945d4e662253859a501a06afa8649ade77a9d877aa9b7", "dest": "offline-repository/org/jetbrains/kotlin/fus-statistics-gradle-plugin/2.3.10", "dest-filename": "fus-statistics-gradle-plugin-2.3.10.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-allopen/2.3.10/kotlin-allopen-2.3.10.pom", "sha512": "b10558b0fe55dcd52cd79a3c15cb2c1e36c88e54a5465f14b404f0ffec1c2a1df5cca33fcf642ffafb0483fc037b9e2153f5389cc5f01880fb7f7a8f20819751", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-allopen/2.3.10", "dest-filename": "kotlin-allopen-2.3.10.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-assignment/2.3.10/kotlin-assignment-2.3.10.pom", "sha512": "89de8a58f65a73eab2656395d1a0ce17681402589a0fd9f20eb867de4cda062877dd20e644e37bcddae7c992552fb0242a32431155b223caaa2a817f28c2e4f9", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-assignment/2.3.10", "dest-filename": "kotlin-assignment-2.3.10.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-build-statistics/2.3.10/kotlin-build-statistics-2.3.10.jar", "sha512": "81d0d361fc05e62acc2411abf34adbfa91c7ba9fed065b9f3917f3b8dfabf298d14c52fb0d6fe1a497ca041a4ad6265c597558595c3044eb17d696e34cad8fd7", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-build-statistics/2.3.10", "dest-filename": "kotlin-build-statistics-2.3.10.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-build-statistics/2.3.10/kotlin-build-statistics-2.3.10.pom", "sha512": "1b54450707f6c3001978ac809c3c924f75e9846042065644773a2b89e950cc9c431fcd602cc425627914fee523309d1a10e916e6809024d407bfdab6e1455baa", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-build-statistics/2.3.10", "dest-filename": "kotlin-build-statistics-2.3.10.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-build-tools-api/2.3.10/kotlin-build-tools-api-2.3.10.jar", "sha512": "6b8619c015fc3fc0e1668649358adead2916909f713e2be988a9799183e94a9ccbc5dc46610aef96da66e74eccfe77233719641e068d2f4f90d66d1ed6aae9d1", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-build-tools-api/2.3.10", "dest-filename": "kotlin-build-tools-api-2.3.10.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-build-tools-api/2.3.10/kotlin-build-tools-api-2.3.10.pom", "sha512": "b8b09e88d3ac6ec5b79cf8ca65a997b633ceb7065ac80b1a64e19d5016bb00baa674f2879384597cbf758211d4f5aa483136d5ccb6dba5bc2bcf2ddbbbdb79d3", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-build-tools-api/2.3.10", "dest-filename": "kotlin-build-tools-api-2.3.10.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-compiler-runner/2.3.10/kotlin-compiler-runner-2.3.10.jar", "sha512": "55add2f091f67c651cbdd7c912f58466f3e402893c64164aae51094a4f2eb063e020660ef7f9b0770b9bb61ced0829a1906226f4c82d4a54d376b9d56c49e3b3", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-compiler-runner/2.3.10", "dest-filename": "kotlin-compiler-runner-2.3.10.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-compiler-runner/2.3.10/kotlin-compiler-runner-2.3.10.pom", "sha512": "8c15301805ef93f0f637be11122fcec101e9927a40805b0534de303bb65f0403136fe458b285a972d042a9b99ad1fe48f8a5d1ae0d21cb4fa19df6829b0e3909", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-compiler-runner/2.3.10", "dest-filename": "kotlin-compiler-runner-2.3.10.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-daemon-client/2.3.10/kotlin-daemon-client-2.3.10.jar", "sha512": "74c66b696eaf9f5fabc6f18914154dbdd21c890294344e95638738a37845afaff2157e9ff72c024a02530a25c9f17ab5c3c8755124ff6ab02ce64eb76cc187fb", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-daemon-client/2.3.10", "dest-filename": "kotlin-daemon-client-2.3.10.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-daemon-client/2.3.10/kotlin-daemon-client-2.3.10.pom", "sha512": "b607b22a940465aa9448fbb19fdc2fe25e2033165b433cbdd83277aca4f14bd6089ac8cdeeb4c64993ca1f83957558f71f00d513bfdfdba39943b6b53dd4889c", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-daemon-client/2.3.10", "dest-filename": "kotlin-daemon-client-2.3.10.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-dataframe/2.3.10/kotlin-dataframe-2.3.10.pom", "sha512": "c5a470578fcd93fa70ed31adfe196c015de0ec3624bde17f115ab5ea43440536cd5e4cabfc751df37dbc3ab3236f9ca5f0b623028b370d3864cce184a7bd03f8", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-dataframe/2.3.10", "dest-filename": "kotlin-dataframe-2.3.10.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-gradle-ecosystem-plugin/2.3.10/kotlin-gradle-ecosystem-plugin-2.3.10.pom", "sha512": "40ec3b42077ceaaba0296427c86e248e7cfb545af0baef82b92a31c36ae9d55c33e31de72bf9573b20827c98aa0121c3fbe389d55296d11041ea11d765b32b65", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-gradle-ecosystem-plugin/2.3.10", "dest-filename": "kotlin-gradle-ecosystem-plugin-2.3.10.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-gradle-plugin-annotations/2.3.10/kotlin-gradle-plugin-annotations-2.3.10.jar", "sha512": "3b4a7870824a4db7068492e0242b74301eb6eba2325a37e1688e5ea1e0d0995e1c515c47131816e7d48860bd69775ba793d84bbf42ccd9e48f4bd6295060dfc3", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-gradle-plugin-annotations/2.3.10", "dest-filename": "kotlin-gradle-plugin-annotations-2.3.10.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-gradle-plugin-annotations/2.3.10/kotlin-gradle-plugin-annotations-2.3.10.pom", "sha512": "980d4664e142d18e67d9aa10b12dd1aad208a4e37f39b02f87e0a93948d6d668c04021ef57ddfef23432ec65bbefa941f1bef06a6bfce0281a23e5b255c2f212", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-gradle-plugin-annotations/2.3.10", "dest-filename": "kotlin-gradle-plugin-annotations-2.3.10.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-gradle-plugin-api/2.3.10/kotlin-gradle-plugin-api-2.3.10-gradle813.jar", "sha512": "c735e3b15461fec4404c3fd380d5ba2a4c2dadb66d8de2cffbbb4c8977f92cb42d25c9036e5caed83e08f5287b885bfc42de38688851fad4e6dafee8ec73e6f9", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-gradle-plugin-api/2.3.10", "dest-filename": "kotlin-gradle-plugin-api-2.3.10-gradle813.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-gradle-plugin-api/2.3.10/kotlin-gradle-plugin-api-2.3.10.module", "sha512": "4c75521b872db0eb92072d0ef01266d7c89543f90b3efbdde64df1c9a776af49d9fe2660e122b338765bda64e162eac9088002c402b7a5c6a17e8315d0ef30d2", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-gradle-plugin-api/2.3.10", "dest-filename": "kotlin-gradle-plugin-api-2.3.10.module" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-gradle-plugin-api/2.3.10/kotlin-gradle-plugin-api-2.3.10.pom", "sha512": "6340032af1caa8d7d3e0244f1298b86cbabeb2e60825c0eccf6b8e54ec05f1d6d8ea60836ecd7753b65e85cca4189e5162d14d1321f1715934eb1ba4bcf58b28", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-gradle-plugin-api/2.3.10", "dest-filename": "kotlin-gradle-plugin-api-2.3.10.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-gradle-plugin-idea-proto/2.3.10/kotlin-gradle-plugin-idea-proto-2.3.10.jar", "sha512": "6393f837e7e07b84a9c6ac82110eb3cdfa230ef655f8044f9f23432e350b5a4d16813752219912e08c5cbf80d80b817e6d9bdfbf37827553347e576f8256560f", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-gradle-plugin-idea-proto/2.3.10", "dest-filename": "kotlin-gradle-plugin-idea-proto-2.3.10.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-gradle-plugin-idea-proto/2.3.10/kotlin-gradle-plugin-idea-proto-2.3.10.pom", "sha512": "3b4357875326dbcfda1cd8307d2bf6be6dcdbc20ac47d774b2b1fd1cb4905f127a50a228fa581842074bf286f64c3eb9d37a6d7c1a560cfd76630acf4418fe23", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-gradle-plugin-idea-proto/2.3.10", "dest-filename": "kotlin-gradle-plugin-idea-proto-2.3.10.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-gradle-plugin-idea/2.3.10/kotlin-gradle-plugin-idea-2.3.10.jar", "sha512": "82a20b477b6105fa99e83cfeee8ba7a5ca41c34e78ccdffc18405c21a405c865e8c0bd4af246d1604e5c62ac4ff6be16fad8bc2f7f48bc41996203c96fd95ece", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-gradle-plugin-idea/2.3.10", "dest-filename": "kotlin-gradle-plugin-idea-2.3.10.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-gradle-plugin-idea/2.3.10/kotlin-gradle-plugin-idea-2.3.10.module", "sha512": "35b7719617e681d9c9cb1ad1555ce1eea9a979aeba1d30c28c29e93cd4b7e05b47decc4fc07da4660eb8342edf3f93e1499fc7a151c485e86e53848604fd8e06", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-gradle-plugin-idea/2.3.10", "dest-filename": "kotlin-gradle-plugin-idea-2.3.10.module" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-gradle-plugin-idea/2.3.10/kotlin-gradle-plugin-idea-2.3.10.pom", "sha512": "5210b0b355487174a4db75b84e30cfbe2013c13c24ba0d177c475ac02dd818bdd77a45d139c7ee880cc101f7d8a520743e6a6edae6437236f137663b6b031ccf", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-gradle-plugin-idea/2.3.10", "dest-filename": "kotlin-gradle-plugin-idea-2.3.10.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-gradle-plugin/2.3.10/kotlin-gradle-plugin-2.3.10-gradle813.jar", "sha512": "437c8570de7193e7f53a83c760d0ce6abea7ddf2fe647a9098e26af0ba654c5f5709634a2160a2e775559a1ff353aac331cfa04161f77abaa67881e75b33ba70", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-gradle-plugin/2.3.10", "dest-filename": "kotlin-gradle-plugin-2.3.10-gradle813.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-gradle-plugin/2.3.10/kotlin-gradle-plugin-2.3.10.module", "sha512": "582111fc42190ea8a27bbd729cb494830c47e60692be42563ff32463b174140f02584a971edcdfee074914a074b0e19f7e53ac4d7c162449ff8ba35f8ef647c5", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-gradle-plugin/2.3.10", "dest-filename": "kotlin-gradle-plugin-2.3.10.module" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-gradle-plugin/2.3.10/kotlin-gradle-plugin-2.3.10.pom", "sha512": "9394bd57bc878dbb8c427507a47eeeffbb00badec2e87e26ce55020d4c8a17b216350193399a1dd2792458439439c0ce834b54bd5ed874550f9a44e35fb7d542", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-gradle-plugin/2.3.10", "dest-filename": "kotlin-gradle-plugin-2.3.10.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-gradle-plugins-bom/2.3.10/kotlin-gradle-plugins-bom-2.3.10.module", "sha512": "289f9d6407d8db850439aafb2a5868ee3618bdc4815e6e9a624880a5d7d9a29ed40394e7ffa0a4dddad5270dfd1bf422d07e71706ec045ffc208a49dfa47ade2", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-gradle-plugins-bom/2.3.10", "dest-filename": "kotlin-gradle-plugins-bom-2.3.10.module" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-gradle-plugins-bom/2.3.10/kotlin-gradle-plugins-bom-2.3.10.pom", "sha512": "d02e66297461c67311016054bcefc533bad158770892dee09ce8d9230813d42b259077693fe71cee30d6e45f1f2bf0cb04dc291e5147f159e1cc8c16c4b15e5f", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-gradle-plugins-bom/2.3.10", "dest-filename": "kotlin-gradle-plugins-bom-2.3.10.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-klib-commonizer-api/2.3.10/kotlin-klib-commonizer-api-2.3.10.jar", "sha512": "0738d61e9230467269076f1b46cdf49cb8df562eb0f6849c2f09a8ff90c724afacfbf5e4a89c25ca440f0e47135306fc8017492bfd954486cd84133cf197e071", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-klib-commonizer-api/2.3.10", "dest-filename": "kotlin-klib-commonizer-api-2.3.10.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-klib-commonizer-api/2.3.10/kotlin-klib-commonizer-api-2.3.10.pom", "sha512": "c2701aeef668c211be5cd75262821f54e3c8309f5630c78503e4f161dcc447d006b516245c781ebaa1e5ac3e8c7a68a93816fcf88e7fdc7d920d65cafe6ecbce", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-klib-commonizer-api/2.3.10", "dest-filename": "kotlin-klib-commonizer-api-2.3.10.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-lombok/2.3.10/kotlin-lombok-2.3.10.pom", "sha512": "63035a7be9b0329606d03f78a39c03039df637bc75e5cf127272303b1e7c3921129ccb138848a33dc1e106bda7f0de27cdb9e6e602b196c8e67afd57ab579eb1", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-lombok/2.3.10", "dest-filename": "kotlin-lombok-2.3.10.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-native-utils/2.3.10/kotlin-native-utils-2.3.10.jar", "sha512": "0e55f086d3a82ce4d2c2179768becf2a52a8a08a37d27dc2976ed5f20f9470272adbcc99f6e389cb69d2eafc86f6da3f65be9d336206ad5d2607b1553e00dcb5", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-native-utils/2.3.10", "dest-filename": "kotlin-native-utils-2.3.10.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-native-utils/2.3.10/kotlin-native-utils-2.3.10.pom", "sha512": "ae651f77a3296b9f22fa6d3d51c229fea1eb9300a77056ad2a956f5e2114ced6e68e53ed23da48b085b0472f27f413ab47dc74d0836baacdda3f9a271593e8ef", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-native-utils/2.3.10", "dest-filename": "kotlin-native-utils-2.3.10.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-noarg/2.3.10/kotlin-noarg-2.3.10.pom", "sha512": "b06872b0862023d7c6c64df8a6a8920c2867a8453fb91be82f9c84608a8dc00d2617ce7188a23b940097d827ae1f6bdbc24bceb5ea29f1dc408315d4e548221e", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-noarg/2.3.10", "dest-filename": "kotlin-noarg-2.3.10.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-power-assert/2.3.10/kotlin-power-assert-2.3.10.pom", "sha512": "f00bfe942d7899c4955c5d13a3d1ef4a14d294bc25c91a8dab3b52b2574303814c3e26bd2782849e1f08eb7ecbfd68aa90939164a9792128f2fe09a80d5cc3f8", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-power-assert/2.3.10", "dest-filename": "kotlin-power-assert-2.3.10.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-reflect/2.0.21/kotlin-reflect-2.0.21.jar", "sha512": "3b35fb5684bc7cad47a5c068b2671ae55cabe6ca16745277ac44ce0785b1384ad35444fbe10b68ca7c360ba9a86b0061a6101956b370e6c19bd8c85c9bc13dcd", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-reflect/2.0.21", "dest-filename": "kotlin-reflect-2.0.21.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-reflect/2.0.21/kotlin-reflect-2.0.21.pom", "sha512": "39be2e959d5c2e2e22445edcfb41b43497b2ad5edfc936081a6a72fcf8b9291571f5d3d6a7ef5d7eb30ddd2cff744966f68ddfea2da33120bf30326e12492425", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-reflect/2.0.21", "dest-filename": "kotlin-reflect-2.0.21.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-sam-with-receiver/2.3.10/kotlin-sam-with-receiver-2.3.10.pom", "sha512": "c7c9b6f9ec69c2f2342f6a795131dccd8e89419dcd6655785275e3cc4e8f92fe9cd9d81db5179e1993dbe8732a24f77712024a3858981d618d2379a8376ae714", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-sam-with-receiver/2.3.10", "dest-filename": "kotlin-sam-with-receiver-2.3.10.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-serialization/2.3.10/kotlin-serialization-2.3.10-gradle813.jar", "sha512": "b33beaba9579e1d23e5795fd65e4d322fbf4a1cd84b65763a11ab58894133a75281607844b14fd18e4d6b9963cff5b6d5d784342dda66372a408840a2fef60fa", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-serialization/2.3.10", "dest-filename": "kotlin-serialization-2.3.10-gradle813.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-serialization/2.3.10/kotlin-serialization-2.3.10.module", "sha512": "24e6ac17ae4f3e0a5c7b3d4e70c240a778af5be3ff5b1144482862d86b537f2fe89b6f0adcbfab417e77dd5ca1d2735e9d5e3e296ebfbd71f6e6af4da650f74f", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-serialization/2.3.10", "dest-filename": "kotlin-serialization-2.3.10.module" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-serialization/2.3.10/kotlin-serialization-2.3.10.pom", "sha512": "ce290ee2f27d77f3d722d7df8ea0d3eefbca899a75f11e814c93f1ffdf33b5e8b67e997d6672bfc78964efa515fa7e9e5076be02f78633d78597f2af29e1db86", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-serialization/2.3.10", "dest-filename": "kotlin-serialization-2.3.10.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-stdlib-common/2.0.21/kotlin-stdlib-common-2.0.21.pom", "sha512": "85a2f145f964d2296694ba5ab03f116268267fcca5eaf1f63445b5bfb3478997057d57f8e60661979e7aa55fc028728ac3aa7a385a4ef7109e904f104c59308e", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-stdlib-common/2.0.21", "dest-filename": "kotlin-stdlib-common-2.0.21.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-stdlib-jdk7/1.8.0/kotlin-stdlib-jdk7-1.8.0.pom", "sha512": "f18fd89c03d5abccf2eb2a8306044dc505e29a2bb97e2d8afdd102d7d8c42908494182daa6133e8dceb221c8bcb9a52f62c7a7232c6657abb9c91c77b2a65af7", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-stdlib-jdk7/1.8.0", "dest-filename": "kotlin-stdlib-jdk7-1.8.0.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-stdlib-jdk7/2.2.0/kotlin-stdlib-jdk7-2.2.0.jar", "sha512": "f1b3e9cf0bb85f66d553550fc6c3dd6a0a6937276879b4deed2b2f0d9f18eb146e4b6dc96b28fe7c257a02f20b5a62cbc20b52d3d9f8a034a8ea12320453687f", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-stdlib-jdk7/2.2.0", "dest-filename": "kotlin-stdlib-jdk7-2.2.0.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-stdlib-jdk7/2.2.0/kotlin-stdlib-jdk7-2.2.0.pom", "sha512": "76252bb08774b7cd60e19090295730249fa2d9187bdede24c7b8682cbac53d78f6fc18e7ff38cdce990537a26d29394bd5c92d1e25034ebbc2a71c80667d0c08", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-stdlib-jdk7/2.2.0", "dest-filename": "kotlin-stdlib-jdk7-2.2.0.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-stdlib-jdk8/1.8.0/kotlin-stdlib-jdk8-1.8.0.pom", "sha512": "2c5b573c0e5fcd9137b614586f7e4b5becaff8edcd15431e785bce5014bd23e3c70f22be5fd95ab15ab99326fddc2b27fcdc9726312a59c4d47d34e88940d3f9", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-stdlib-jdk8/1.8.0", "dest-filename": "kotlin-stdlib-jdk8-1.8.0.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-stdlib-jdk8/2.2.0/kotlin-stdlib-jdk8-2.2.0.jar", "sha512": "6307f0854f21811b0b5ff54ada950d44391a813fa41bf3c0de74ebee4d5ca6b861eaa1bec9e6aa991c7942ea0b67a6504adb8198dd5a42d86240d58465e29dfb", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-stdlib-jdk8/2.2.0", "dest-filename": "kotlin-stdlib-jdk8-2.2.0.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-stdlib-jdk8/2.2.0/kotlin-stdlib-jdk8-2.2.0.pom", "sha512": "4a49e2e4635c792e0f57f45e7f2463a95d2d9af92a8c62d1ca6f1460bc231afca5968e65667fa87e02a9dc129fa815ca2c1ba804e9780c2c17b2886a76790acd", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-stdlib-jdk8/2.2.0", "dest-filename": "kotlin-stdlib-jdk8-2.2.0.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-stdlib/2.0.21/kotlin-stdlib-2.0.21.jar", "sha512": "4e6786c4e7af13eddc5a2a06b336f430987f16ac1f4c0af541cd97400d689f7d8726a276ca549e7f19d7b939f47a1535dff7ea45f76b14197a0e876cc44d3acd", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-stdlib/2.0.21", "dest-filename": "kotlin-stdlib-2.0.21.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-stdlib/2.0.21/kotlin-stdlib-2.0.21.module", "sha512": "28d2618070b9a596ed01a547f360d21bee2d504020952cda8b8a914ee05454b6560eacb4d4d602ae61b915559e76acbb3bcc96b25fe76140ec14cca5d8c07261", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-stdlib/2.0.21", "dest-filename": "kotlin-stdlib-2.0.21.module" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-stdlib/2.0.21/kotlin-stdlib-2.0.21.pom", "sha512": "8b1f1ffcd1fdc8f71183f80fad469036a6bdfce169c5691484070259fc37d3eab7879e0658ef605e5609469a18c9c16f77f3123de1f0605153feff616985beb0", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-stdlib/2.0.21", "dest-filename": "kotlin-stdlib-2.0.21.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-tooling-core/2.3.10/kotlin-tooling-core-2.3.10.jar", "sha512": "831ec9092a896fefa8c4b2bd0c2304307dfe812ad83738e83870ae4de75d3d829cd89c576e9daadcda5c8d54777371bb5611482cfd9d6bab5fe1645dda7132a3", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-tooling-core/2.3.10", "dest-filename": "kotlin-tooling-core-2.3.10.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-tooling-core/2.3.10/kotlin-tooling-core-2.3.10.pom", "sha512": "deddb4bb53eea087d5037914a3e41284503617fe6c96d4d953c9d8722ae912901aa5c0cd3371157265221c7822b4980eb899709d7fc1d942ac9572b0f6d75472", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-tooling-core/2.3.10", "dest-filename": "kotlin-tooling-core-2.3.10.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-util-io/2.3.10/kotlin-util-io-2.3.10.jar", "sha512": "16d63d88531855789ba7a6d60ddf4af60f5d2fe89a08cbaa9e457a68ae3e0252d6247abc5a05a57ad0845d0820e4d6892b67d732c33b50a3a6c07f93f6cbff27", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-util-io/2.3.10", "dest-filename": "kotlin-util-io-2.3.10.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-util-io/2.3.10/kotlin-util-io-2.3.10.pom", "sha512": "930a58a550e65c320123b5ff30fade4788e9869e425b524d56f2b3a154175aa308fb68afc8cae04d2caf4d78cc0e1d2c9710629bb47c686906c4223582b95c66", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-util-io/2.3.10", "dest-filename": "kotlin-util-io-2.3.10.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-util-klib-metadata/2.3.10/kotlin-util-klib-metadata-2.3.10.jar", "sha512": "4c8d0e43d6ee8c76f9c007ad48ed1db93dc89cda7bf43d4015f689b48dc486cee4470b9f8b597dbae4562be14ee24826071eb328ba78d443b8d4821eab10c8a8", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-util-klib-metadata/2.3.10", "dest-filename": "kotlin-util-klib-metadata-2.3.10.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-util-klib-metadata/2.3.10/kotlin-util-klib-metadata-2.3.10.pom", "sha512": "ae9ca9cc720897b485fa63ff4a069c72f3e9855dfa120d8a07275b84ec44fd48e35c6dd387e904c8e17c15f53416af071d509423e4bd5eaf9e091fbd646784e9", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-util-klib-metadata/2.3.10", "dest-filename": "kotlin-util-klib-metadata-2.3.10.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-util-klib/2.3.10/kotlin-util-klib-2.3.10.jar", "sha512": "0db8e49cc4ec210543e074d5e806b95db6493137d0c89033fab57b3e1aa87f19cebed498c94c136e768a84957601bb6aaf99e6badc0caf66c58086d546de0ed2", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-util-klib/2.3.10", "dest-filename": "kotlin-util-klib-2.3.10.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-util-klib/2.3.10/kotlin-util-klib-2.3.10.pom", "sha512": "14c8ff493c14414dc9ceb893125d8026589bddcea04896dceaa4e91aad383f56270ce5e88aa8122adc81aa57ce3fed16a7b974d70c086dfe35b511103877fc58", "dest": "offline-repository/org/jetbrains/kotlin/kotlin-util-klib/2.3.10", "dest-filename": "kotlin-util-klib-2.3.10.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/multiplatform/org.jetbrains.kotlin.multiplatform.gradle.plugin/2.3.10/org.jetbrains.kotlin.multiplatform.gradle.plugin-2.3.10.pom", "sha512": "2a92c828efbfc35129490ef52821ba167bbf924f90122cb48419670de8d032e2b340828adb5de908538e0097c4bdb16245789fdcfd31b201ab89417826018f76", "dest": "offline-repository/org/jetbrains/kotlin/multiplatform/org.jetbrains.kotlin.multiplatform.gradle.plugin/2.3.10", "dest-filename": "org.jetbrains.kotlin.multiplatform.gradle.plugin-2.3.10.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/plugin/compose/org.jetbrains.kotlin.plugin.compose.gradle.plugin/2.3.10/org.jetbrains.kotlin.plugin.compose.gradle.plugin-2.3.10.pom", "sha512": "6c555afbbcef56cd0c9040e9545ea168d1edf9bb947bf6e215ab2d54877f83499b257ebaa2c761b2148b2cd52ab4daa576fcc598a62a7985b6dab5753b1b7b40", "dest": "offline-repository/org/jetbrains/kotlin/plugin/compose/org.jetbrains.kotlin.plugin.compose.gradle.plugin/2.3.10", "dest-filename": "org.jetbrains.kotlin.plugin.compose.gradle.plugin-2.3.10.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/plugin/serialization/org.jetbrains.kotlin.plugin.serialization.gradle.plugin/2.3.10/org.jetbrains.kotlin.plugin.serialization.gradle.plugin-2.3.10.pom", "sha512": "f48cfff9d983d82a6e1939bf432f79adca26f51a6d1bae72110a3102d05e88590e2dcfd98722ca7c03f103926ff2ce196cbffd8ddf6f5bf485b3aaaadf4d4c8b", "dest": "offline-repository/org/jetbrains/kotlin/plugin/serialization/org.jetbrains.kotlin.plugin.serialization.gradle.plugin/2.3.10", "dest-filename": "org.jetbrains.kotlin.plugin.serialization.gradle.plugin-2.3.10.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-android/1.9.0/kotlinx-coroutines-android-1.9.0.pom", "sha512": "f072c0f0b90ff949580b5d0c195f5051cb7e5245ee8fa4fad2c49e4f8a8344521ff2ba3d113342aa0bc7a40587f28dffe4d0375c17f9d659d5189c4b4c94e6c4", "dest": "offline-repository/org/jetbrains/kotlinx/kotlinx-coroutines-android/1.9.0", "dest-filename": "kotlinx-coroutines-android-1.9.0.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-bom/1.9.0/kotlinx-coroutines-bom-1.9.0.pom", "sha512": "6d7f6ee873c622ac45c12266c1b2dc7086cb7273a77215ef9c51ce3b8cc1ac3415229b193792d1900d0ed9fba3f464eaa380741e813ed4ed364a6bc21de17671", "dest": "offline-repository/org/jetbrains/kotlinx/kotlinx-coroutines-bom/1.9.0", "dest-filename": "kotlinx-coroutines-bom-1.9.0.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-core-jvm/1.9.0/kotlinx-coroutines-core-jvm-1.9.0.jar", "sha512": "78c77268ec81ee580c9481ae35b667912e186e11576acc669ad4e8c17041506db5ba8adae439b50685a7cedf58755f0df7f2aa82ad1350e499431b13e6752a1a", "dest": "offline-repository/org/jetbrains/kotlinx/kotlinx-coroutines-core-jvm/1.9.0", "dest-filename": "kotlinx-coroutines-core-jvm-1.9.0.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-core-jvm/1.9.0/kotlinx-coroutines-core-jvm-1.9.0.module", "sha512": "2a2ddb002d60f0a4bc757b8deacd2a89b1a86c6601bd52911af701728cc0b796e3ae743b4f2b847036eeb8ce58141a0f438ee600ba198cd55e95031af7081e4e", "dest": "offline-repository/org/jetbrains/kotlinx/kotlinx-coroutines-core-jvm/1.9.0", "dest-filename": "kotlinx-coroutines-core-jvm-1.9.0.module" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-core-jvm/1.9.0/kotlinx-coroutines-core-jvm-1.9.0.pom", "sha512": "d04fca8b3b465e781f48b7903ff8d880ca305a66c038e02da51ca87a0ee5c96275f911c673d779a364e82a325e427f4c11f5f83ed3c815cd75f6291fb96c0625", "dest": "offline-repository/org/jetbrains/kotlinx/kotlinx-coroutines-core-jvm/1.9.0", "dest-filename": "kotlinx-coroutines-core-jvm-1.9.0.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-core/1.9.0/../../kotlinx-coroutines-core-jvm/1.9.0/kotlinx-coroutines-core-jvm-1.9.0.module", "sha512": "2a2ddb002d60f0a4bc757b8deacd2a89b1a86c6601bd52911af701728cc0b796e3ae743b4f2b847036eeb8ce58141a0f438ee600ba198cd55e95031af7081e4e", "dest": "offline-repository/org/jetbrains/kotlinx/kotlinx-coroutines-core/1.9.0/../../kotlinx-coroutines-core-jvm/1.9.0", "dest-filename": "kotlinx-coroutines-core-jvm-1.9.0.module" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-core/1.9.0/kotlinx-coroutines-core-1.9.0.module", "sha512": "e41d384e7e11561a243f838c49bbdf031354ebf51690cfb23aba2e734c8220729ac1b339938853c4b658f0440272fbe09465e17be46e231e21898a9d4fbdc84c", "dest": "offline-repository/org/jetbrains/kotlinx/kotlinx-coroutines-core/1.9.0", "dest-filename": "kotlinx-coroutines-core-1.9.0.module" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-core/1.9.0/kotlinx-coroutines-core-1.9.0.pom", "sha512": "a0031bc41d95c457e2be4ba52dd7af151e5b7519cf01591ea258b7654054666f98f1f82911e4d8282e4d574db3dfffab917031d4e5493acf6df006469f24dbb5", "dest": "offline-repository/org/jetbrains/kotlinx/kotlinx-coroutines-core/1.9.0", "dest-filename": "kotlinx-coroutines-core-1.9.0.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-debug/1.9.0/kotlinx-coroutines-debug-1.9.0.pom", "sha512": "dee256f7eabe987e69ed8065143f84467df166fb8ed9b7c9a1f86461752826e5561bd90aa6065106374eaf6e8d4f77d8567828b119a5cc7df919acf57d0d8261", "dest": "offline-repository/org/jetbrains/kotlinx/kotlinx-coroutines-debug/1.9.0", "dest-filename": "kotlinx-coroutines-debug-1.9.0.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-guava/1.9.0/kotlinx-coroutines-guava-1.9.0.pom", "sha512": "7d2eb5891bc73fc89dccaf75aa19f75662c79c4c00e79d15aa9908df7c845e2748ad00561766ec4344ee5d8fa2fae6bf58fbee00042fca0ade4974d683e43afa", "dest": "offline-repository/org/jetbrains/kotlinx/kotlinx-coroutines-guava/1.9.0", "dest-filename": "kotlinx-coroutines-guava-1.9.0.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-javafx/1.9.0/kotlinx-coroutines-javafx-1.9.0.pom", "sha512": "841f9e626bca051af2b1c6929b79580dc22b1d8ba3387316b5ae058f28d3d3e481089b517743f0d41add210e11acad7b8b43d4dc1f05e4a9102f0b3b37cfb0cc", "dest": "offline-repository/org/jetbrains/kotlinx/kotlinx-coroutines-javafx/1.9.0", "dest-filename": "kotlinx-coroutines-javafx-1.9.0.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-jdk8/1.9.0/kotlinx-coroutines-jdk8-1.9.0.pom", "sha512": "9c857e4f76314b851de229255107e553b30f8f7e166baadb97c81d436bef07021d7e22f821842122417dd33030fea30a7b5bff31de7cdac354dfefdd27b05e2e", "dest": "offline-repository/org/jetbrains/kotlinx/kotlinx-coroutines-jdk8/1.9.0", "dest-filename": "kotlinx-coroutines-jdk8-1.9.0.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-jdk9/1.9.0/kotlinx-coroutines-jdk9-1.9.0.pom", "sha512": "55d85bd1b545e14f15bd15ec33ac6124527c5d5bb4b155de3989b49562998147f0a9d284271b22013c48c26e4da197841646d5bd3297416477182c9c1861dbda", "dest": "offline-repository/org/jetbrains/kotlinx/kotlinx-coroutines-jdk9/1.9.0", "dest-filename": "kotlinx-coroutines-jdk9-1.9.0.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-play-services/1.9.0/kotlinx-coroutines-play-services-1.9.0.pom", "sha512": "62209ffa7b931c247ac908cc26f23848206ee04d9641c67a44041cf6721bc36abc6fb49a9ebdcfa537063d88e1820e9d693c0ee96d916b6e8f329a95487c4bed", "dest": "offline-repository/org/jetbrains/kotlinx/kotlinx-coroutines-play-services/1.9.0", "dest-filename": "kotlinx-coroutines-play-services-1.9.0.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-reactive/1.9.0/kotlinx-coroutines-reactive-1.9.0.pom", "sha512": "626c539907c593900b7ef87e65aace18715023c88069a55e23ae2cfa057172c8b0d46ba5fdd37556cfad5b40237bcbce84ec1bac3578ab3acebcaee6d4c2830f", "dest": "offline-repository/org/jetbrains/kotlinx/kotlinx-coroutines-reactive/1.9.0", "dest-filename": "kotlinx-coroutines-reactive-1.9.0.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-reactor/1.9.0/kotlinx-coroutines-reactor-1.9.0.pom", "sha512": "3c1e81de7177dda2b7f6b456b90c7243d5b9dfa8b337b8c489d0ac859bcc45d1a585db2f3b0938c59c5bed55e890c8b8a69b467b3250792a1076811f38b1ceb8", "dest": "offline-repository/org/jetbrains/kotlinx/kotlinx-coroutines-reactor/1.9.0", "dest-filename": "kotlinx-coroutines-reactor-1.9.0.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-rx2/1.9.0/kotlinx-coroutines-rx2-1.9.0.pom", "sha512": "f73b1a9c4176e4413f028154b1dfbd773eea996df96b49b05eb107db0a3bd2633663283db423aa186a64b048b1a55374fe44279873e435137b4827768b50e78c", "dest": "offline-repository/org/jetbrains/kotlinx/kotlinx-coroutines-rx2/1.9.0", "dest-filename": "kotlinx-coroutines-rx2-1.9.0.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-rx3/1.9.0/kotlinx-coroutines-rx3-1.9.0.pom", "sha512": "10593151609dbf97648b401f18e44bedd14503bcccbe648d5798c9f19ee31950d815ceffe0db04766636dd63e96a5da4a2b0b79162b6446a9459075dde28b2ed", "dest": "offline-repository/org/jetbrains/kotlinx/kotlinx-coroutines-rx3/1.9.0", "dest-filename": "kotlinx-coroutines-rx3-1.9.0.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-slf4j/1.9.0/kotlinx-coroutines-slf4j-1.9.0.pom", "sha512": "6d789ed372c11771615f12b88a63aefaf4c5b812908b0a3f30a4a2ba6d0e4dd3490012c7db4c269b3df53bcab0fca75b25abed3719625d677320dd61a16fdaa9", "dest": "offline-repository/org/jetbrains/kotlinx/kotlinx-coroutines-slf4j/1.9.0", "dest-filename": "kotlinx-coroutines-slf4j-1.9.0.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-swing/1.9.0/kotlinx-coroutines-swing-1.9.0.pom", "sha512": "a286882296c08d110bf0fdbc6016416e9d91752e74d2840f4418417869380b1a6e1b46c7aa6db847ff19feadffc90104b486e5bb3dc32636ebd7940031acc0e8", "dest": "offline-repository/org/jetbrains/kotlinx/kotlinx-coroutines-swing/1.9.0", "dest-filename": "kotlinx-coroutines-swing-1.9.0.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-test-jvm/1.9.0/kotlinx-coroutines-test-jvm-1.9.0.pom", "sha512": "d5217ec7dde2d2b8553e4b455ce0bd3bcbae64100cd0d8f93f1754e90d770db56b9d81ff5b2abc3e973fab6ebce195e364714c4d8adc47cb2ff7b70e89f51152", "dest": "offline-repository/org/jetbrains/kotlinx/kotlinx-coroutines-test-jvm/1.9.0", "dest-filename": "kotlinx-coroutines-test-jvm-1.9.0.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-test/1.9.0/kotlinx-coroutines-test-1.9.0.pom", "sha512": "05fe09d332ba7df1238d79f839b2f9c28f24c4ec546a3a84f930ba524c3905c66c87be97f4b5ef33f6f0f83fe7f522b5d75dc4755cd8e9b70e95ec0a32d72f14", "dest": "offline-repository/org/jetbrains/kotlinx/kotlinx-coroutines-test/1.9.0", "dest-filename": "kotlinx-coroutines-test-1.9.0.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/junit/junit-bom/5.10.2/junit-bom-5.10.2.pom", "sha512": "5b36199c9a331c4d05ed78b8fe1f28c5d21e0d7a05bdca1d45f532a89eb7d67fd425da93e9405203a483354abc63718858f80c828e0c090316f70fd833ef904c", "dest": "offline-repository/org/junit/junit-bom/5.10.2", "dest-filename": "junit-bom-5.10.2.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jvnet/staxex/stax-ex/1.8.1/stax-ex-1.8.1.jar", "sha512": "d060ea5dcc81508a1078bd0ed947553a5652a5a78da76cd00dc2e384dca014166dfe92777fceb7d7868699894d7e40ecef99fac7771372025bfd7f1fa3e2fe32", "dest": "offline-repository/org/jvnet/staxex/stax-ex/1.8.1", "dest-filename": "stax-ex-1.8.1.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/jvnet/staxex/stax-ex/1.8.1/stax-ex-1.8.1.pom", "sha512": "430d30c5de912c6025635e03741451f1735cb0362327d9d2cf8c8612da5a265cb25f05015ef9156480966c41cf84b62342f921dcef65a27af0c1038dea0cd850", "dest": "offline-repository/org/jvnet/staxex/stax-ex/1.8.1", "dest-filename": "stax-ex-1.8.1.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/ow2/asm/asm-analysis/9.8/asm-analysis-9.8.jar", "sha512": "0268e6dc2cc4965180ca1b62372e3c5fc280d6dc09cfeace2ac4e43468025e8a78813e4e93beafc0352e67498c70616cb4368313aaab532025fa98146c736117", "dest": "offline-repository/org/ow2/asm/asm-analysis/9.8", "dest-filename": "asm-analysis-9.8.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/ow2/asm/asm-analysis/9.8/asm-analysis-9.8.pom", "sha512": "7b65663a02d30ac3c499cd63d8b057091a394a73f5187c33f325c4f6dfdf103940ba29e1d02c3f59e0cb262b3015daf5dcb058ff87e0a08762071e47a1f0ffec", "dest": "offline-repository/org/ow2/asm/asm-analysis/9.8", "dest-filename": "asm-analysis-9.8.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/ow2/asm/asm-commons/9.8/asm-commons-9.8.jar", "sha512": "d2add10e25416b701bd84651b42161e090df2f32940de5e06e0e2a41c6106734db2fe5136f661d8a8af55e80dc958bc7b385a1004f0ebe550828dfa1e9d70d41", "dest": "offline-repository/org/ow2/asm/asm-commons/9.8", "dest-filename": "asm-commons-9.8.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/ow2/asm/asm-commons/9.8/asm-commons-9.8.pom", "sha512": "a5137b1ead49660b104f0e8d54b9df646d0f87645905c38e7df0b6d10d7f8e0aabe43db227dc005fd63765eded2554c23fd0495cc25cdf4112447bd3300d9bae", "dest": "offline-repository/org/ow2/asm/asm-commons/9.8", "dest-filename": "asm-commons-9.8.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/ow2/asm/asm-tree/9.8/asm-tree-9.8.jar", "sha512": "4493f573d9f0cfc8837db9be25a8b61a825a06aafc0e02f0363875584ff184a5a14600e53793c09866300859e44f153faffd0e050de4a7fba1a63b5fb010a9a7", "dest": "offline-repository/org/ow2/asm/asm-tree/9.8", "dest-filename": "asm-tree-9.8.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/ow2/asm/asm-tree/9.8/asm-tree-9.8.pom", "sha512": "db2073ff628d0a146bf4c132fc900d9a646476fdeda35cee7f1f0d2a486848d4cbc77fd28bf2a28bd9855e54ec3b8114d2961cee580dd27ce7d48f4ebf2821be", "dest": "offline-repository/org/ow2/asm/asm-tree/9.8", "dest-filename": "asm-tree-9.8.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/ow2/asm/asm-util/9.8/asm-util-9.8.jar", "sha512": "b68048e199c49d2f90b2990c6993f1fcddccd34fb9d91154ef327d874aa5ff8609db5fbd63e23141020cdeda8fb753e97a61c2152e1b4e8f20003a5390e7e1d9", "dest": "offline-repository/org/ow2/asm/asm-util/9.8", "dest-filename": "asm-util-9.8.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/ow2/asm/asm-util/9.8/asm-util-9.8.pom", "sha512": "281e96ee5a2bffc3cd4af80bab993a57ad2833769cd4c3941fa8709a9d552ab36c4336e85dfebd5e98aa7eaceb647211f8d5938f11fdd54edd846a2e8013523c", "dest": "offline-repository/org/ow2/asm/asm-util/9.8", "dest-filename": "asm-util-9.8.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/ow2/asm/asm/9.8/asm-9.8.jar", "sha512": "cbd250b9c698a48a835e655f5f5262952cc6dd1a434ec0bc3429a9de41f2ce08fcd3c4f569daa7d50321ca6ad1d32e131e4199aa4fe54bce9e9691b37e45060e", "dest": "offline-repository/org/ow2/asm/asm/9.8", "dest-filename": "asm-9.8.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/ow2/asm/asm/9.8/asm-9.8.pom", "sha512": "a34a1cc4ac50724e0798f4d8ee677207ba68e4f311d9d5cc200dd9351ea63f556589aa42c7a0df90b53634ae41bae3646d5115eb029aab11077a20f7b144bf3f", "dest": "offline-repository/org/ow2/asm/asm/9.8", "dest-filename": "asm-9.8.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/ow2/ow2/1.5.1/ow2-1.5.1.pom", "sha512": "5dbdf60bace26f9dbe2610d3de178e729fae77d65f57cb8238a828d020aaf1b4cc3d3d804bbdcf4a385c141b14fbf92ff689d9caa1f9e86542b5c47b0b1e9288", "dest": "offline-repository/org/ow2/ow2/1.5.1", "dest-filename": "ow2-1.5.1.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/slf4j/slf4j-api/1.7.30/slf4j-api-1.7.30.jar", "sha512": "e5435852569dda596ba46138af8ee9c4ecba8a7a43f4f1e7897aeb4430523a0f037088a7b63877df5734578f19d331f03d7b0f32d5ae6c425df211947b3e6173", "dest": "offline-repository/org/slf4j/slf4j-api/1.7.30", "dest-filename": "slf4j-api-1.7.30.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/slf4j/slf4j-api/1.7.30/slf4j-api-1.7.30.pom", "sha512": "9a529f73f5409940553054d5f8ff5394ffe50ab772ef6fc9f052f8eb6bd64c3bfa84b615d3257f10b6dee85df0c340194616368b67bb16918af59f4770d38acf", "dest": "offline-repository/org/slf4j/slf4j-api/1.7.30", "dest-filename": "slf4j-api-1.7.30.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/slf4j/slf4j-parent/1.7.30/slf4j-parent-1.7.30.pom", "sha512": "1103234e739366ed3a4a82451723b6a495243ced75c73cf4a9fddd36381a11ecd7f454f5abc7eab13b2f5d594db9192831483aee7a6e26cbd6e4d0cd1eae260d", "dest": "offline-repository/org/slf4j/slf4j-parent/1.7.30", "dest-filename": "slf4j-parent-1.7.30.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/sonatype/oss/oss-parent/7/oss-parent-7.pom", "sha512": "63b0951f793ee9d25239ee44760e4d51de3b8503e438e567862306f2d175019d8617eb854bc4ee2374c39f385e0a1094c3c7097f899b2074e4acda14fe6030fb", "dest": "offline-repository/org/sonatype/oss/oss-parent/7", "dest-filename": "oss-parent-7.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/sonatype/oss/oss-parent/9/oss-parent-9.pom", "sha512": "1c4f18cacd3a9f99a168bd20845d12d94824e66b5caebe57c164b6ac3dc89803508f60e35c4d1da81d3f3866c89ed407826f0d108f1e624c2d8a258e01e1064a", "dest": "offline-repository/org/sonatype/oss/oss-parent/9", "dest-filename": "oss-parent-9.pom" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/tensorflow/tensorflow-lite-metadata/0.2.0/tensorflow-lite-metadata-0.2.0.jar", "sha512": "b09be234ced89c7f6bdcb77af540cc4cd2dfaa3b88a024897add2b79fe925740a42ab7395e01c2bc91228d7bb8679553502cccf07f1a1cd2fa2ddd580ec5d160", "dest": "offline-repository/org/tensorflow/tensorflow-lite-metadata/0.2.0", "dest-filename": "tensorflow-lite-metadata-0.2.0.jar" }, { "type": "file", "url": "https://repo.maven.apache.org/maven2/org/tensorflow/tensorflow-lite-metadata/0.2.0/tensorflow-lite-metadata-0.2.0.pom", "sha512": "54bbfd5a6f9ff7db32d12c2f4dee9769bdb23bad1bebec0c705b79a7a324cdeb51a53c75c03328462272ffff0ea9602ac64bb3ab3649d6df742a2bb4b89e8e55", "dest": "offline-repository/org/tensorflow/tensorflow-lite-metadata/0.2.0", "dest-filename": "tensorflow-lite-metadata-0.2.0.pom" } ] ================================================ FILE: packaging/flatpak/githubstore.sh ================================================ #!/bin/bash # GitHub Store Flatpak launcher script # Launches the uber JAR with the bundled JetBrains Runtime export JAVA_HOME=/app/jre # Ensure config directory exists mkdir -p "${XDG_CONFIG_HOME:-$HOME/.config}/githubstore" mkdir -p "${XDG_DATA_HOME:-$HOME/.local/share}/githubstore" exec /app/jre/bin/java \ -Djava.awt.headless=false \ -Dawt.useSystemAAFontSettings=on \ -Dswing.aatext=true \ -Djava.util.prefs.userRoot="${XDG_CONFIG_HOME:-$HOME/.config}/githubstore" \ -Dapp.data.dir="${XDG_DATA_HOME:-$HOME/.local/share}/githubstore" \ -Dapp.downloads.dir="${XDG_DOWNLOAD_DIR:-$HOME/Downloads}" \ -jar /app/lib/githubstore.jar "$@" ================================================ FILE: packaging/flatpak/zed.rainxch.githubstore.desktop ================================================ [Desktop Entry] Type=Application Name=GitHub Store GenericName=App Store for GitHub Releases Comment=Browse, download, and manage apps from GitHub releases Icon=zed.rainxch.githubstore Exec=githubstore %u Terminal=false StartupNotify=true Categories=Development;PackageManager; Keywords=GitHub;Apps;Releases;APK;Store; StartupWMClass=GitHub-Store MimeType=x-scheme-handler/githubstore; ================================================ FILE: packaging/flatpak/zed.rainxch.githubstore.metainfo.xml ================================================ zed.rainxch.githubstore CC0-1.0 Apache-2.0 GitHub Store A cross-platform app store for GitHub releases

GitHub Store lets you browse, download, and manage applications distributed through GitHub releases. It brings the convenience of an app store to the open-source ecosystem.

Features include:

  • Discover trending, hot, and popular repositories with installable releases
  • Search and filter repositories by language, topic, and more
  • View release notes, changelogs, and repository READMEs with translation support
  • Secure installs with TOFU signing key verification and provenance attestation checks
  • Save favourites and starred repositories for quick access
  • GitHub OAuth authentication for higher API rate limits and personalized experience
  • Material 3 theming with light/dark mode and 12 language translations
zed.rainxch.githubstore.desktop Home screen showing trending repositories https://raw.githubusercontent.com/OpenHub-Store/GitHub-Store/main/media-resources/screenshots/desktop/desktop_linux_home.jpg Repository details with release info https://raw.githubusercontent.com/OpenHub-Store/GitHub-Store/main/media-resources/screenshots/desktop/desktop_linux_details.jpg Installing packages on Linux https://raw.githubusercontent.com/OpenHub-Store/GitHub-Store/main/media-resources/screenshots/desktop/desktop_linux_installing_rpm.jpg AppImage support on Linux https://raw.githubusercontent.com/OpenHub-Store/GitHub-Store/main/media-resources/screenshots/desktop/desktop_linux_appimage.jpg https://github.com/OpenHub-Store/GitHub-Store https://github.com/OpenHub-Store/GitHub-Store/issues https://github.com/OpenHub-Store/GitHub-Store rainxchzed

Security improvements: package name and signing key validation on updates, provenance attestation badges, and app-link verification with asset picker.

Development PackageManager GitHub releases app store package manager APK pointing keyboard 768
================================================ FILE: packaging/flatpak/zed.rainxch.githubstore.yml ================================================ app-id: zed.rainxch.githubstore runtime: org.freedesktop.Platform runtime-version: '24.08' sdk: org.freedesktop.Sdk sdk-extensions: - org.freedesktop.Sdk.Extension.openjdk21 command: githubstore finish-args: # Network access for GitHub API calls and downloading releases - --share=network # X11 display support - --share=ipc - --socket=x11 # GPU acceleration for Compose rendering - --device=dri # Download directory access for APK/release downloads - --filesystem=xdg-download:rw # Wayland support (fallback to X11 via XWayland) - --socket=fallback-x11 - --socket=wayland modules: # Module 1: Bundle JetBrains Runtime (JBR) 21 for optimal Compose Desktop rendering # Using plain jbr (no JCEF) — app doesn't use embedded Chromium - name: jbr buildsystem: simple build-commands: - mkdir -p /app/jre - tar xzf jbr-*.tar.gz -C /app/jre --strip-components=1 sources: # x86_64 - type: file url: https://cache-redirector.jetbrains.com/intellij-jbr/jbr-21.0.10-linux-x64-b1163.105.tar.gz sha256: b6a3b13451d296140727bbde9325dd9ee422e2e9b2c6a9378f346f1b8d1111bc only-arches: - x86_64 # aarch64 - type: file url: https://cache-redirector.jetbrains.com/intellij-jbr/jbr-21.0.10-linux-aarch64-b1163.105.tar.gz sha256: 38804f526d869f5a9c49e9b90c04edcbc918b41e8e43264e5d5076d352a959bc only-arches: - aarch64 # Module 2: Build and install GitHub Store - name: githubstore buildsystem: simple build-options: append-path: /usr/lib/sdk/openjdk21/bin env: JAVA_HOME: /usr/lib/sdk/openjdk21 GRADLE_USER_HOME: /run/build/githubstore/.gradle build-commands: # Use local Gradle distribution (no network in sandbox) - sed -i 's|distributionUrl=.*|distributionUrl=gradle-bin.zip|' gradle/wrapper/gradle-wrapper.properties # Disable Android targets (no Android SDK in Flatpak sandbox) - bash packaging/flatpak/disable-android-for-flatpak.sh # Build uber JAR - ./gradlew :composeApp:packageReleaseUberJarForCurrentOS --no-daemon --offline --no-configuration-cache -Dorg.gradle.jvmargs="-Xmx6g -XX:MaxMetaspaceSize=2g" -Dorg.gradle.parallel=false # Install the JAR - install -Dm644 "$(find composeApp/build/compose/jars/ -name '*.jar' -type f | head -1)" /app/lib/githubstore.jar # Install launcher script - install -Dm755 packaging/flatpak/githubstore.sh /app/bin/githubstore # Install desktop entry - install -Dm644 packaging/flatpak/zed.rainxch.githubstore.desktop /app/share/applications/zed.rainxch.githubstore.desktop # Install AppStream metainfo - install -Dm644 packaging/flatpak/zed.rainxch.githubstore.metainfo.xml /app/share/metainfo/zed.rainxch.githubstore.metainfo.xml # Install icon (512x512) - install -Dm644 composeApp/src/jvmMain/resources/logo/app_icon.png /app/share/icons/hicolor/512x512/apps/zed.rainxch.githubstore.png sources: - type: git url: https://github.com/OpenHub-Store/GitHub-Store tag: v1.6.2 # commit: REPLACE_WITH_COMMIT_HASH # Local Gradle distribution (pre-downloaded, no network in sandbox) - type: file url: https://services.gradle.org/distributions/gradle-8.14.3-bin.zip sha256: bd71102213493060956ec229d946beee57158dbd89d0e62b91bca0fa2c5f3531 dest: gradle/wrapper dest-filename: gradle-bin.zip # Pre-downloaded Maven/Gradle dependencies # Generate with: ./gradlew flatpakGradleGenerator - flatpak-sources.json ================================================ FILE: settings.gradle.kts ================================================ rootProject.name = "GithubStore" enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") pluginManagement { includeBuild("build-logic") repositories { google { mavenContent { includeGroupAndSubgroups("androidx") includeGroupAndSubgroups("com.android") includeGroupAndSubgroups("com.google") } } mavenCentral() gradlePluginPortal() } } dependencyResolutionManagement { repositories { google { mavenContent { includeGroupAndSubgroups("androidx") includeGroupAndSubgroups("com.android") includeGroupAndSubgroups("com.google") } } mavenCentral() } } plugins { id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" } include(":composeApp") include(":core:domain") include(":core:data") include(":core:presentation") include(":feature:apps:data") include(":feature:apps:domain") include(":feature:apps:presentation") include(":feature:auth:domain") include(":feature:auth:data") include(":feature:auth:presentation") include(":feature:details:domain") include(":feature:details:data") include(":feature:details:presentation") include(":feature:dev-profile:presentation") include(":feature:dev-profile:data") include(":feature:dev-profile:domain") include(":feature:favourites:data") include(":feature:favourites:domain") include(":feature:favourites:presentation") include(":feature:home:domain") include(":feature:home:data") include(":feature:home:presentation") include(":feature:starred:domain") include(":feature:starred:data") include(":feature:starred:presentation") include(":feature:search:domain") include(":feature:search:data") include(":feature:search:presentation") include(":feature:profile:domain") include(":feature:profile:data") include(":feature:profile:presentation")