├── .editorconfig
├── .github
├── FUNDING.yml
├── ISSUE_TEMPLATE
│ └── bug_report.md
└── workflows
│ ├── debug-build.yml
│ ├── ktlint.yml
│ └── release-build.yml
├── .gitignore
├── .idea
├── .gitignore
├── .name
├── compiler.xml
├── gradle.xml
├── kotlinc.xml
├── migrations.xml
├── misc.xml
├── runConfigurations.xml
└── vcs.xml
├── LICENSE
├── README.md
├── app
├── .gitignore
├── build.gradle.kts
├── proguard-rules.pro
└── src
│ ├── androidTest
│ └── kotlin
│ │ └── dev
│ │ └── chungjungsoo
│ │ └── gptmobile
│ │ └── ExampleInstrumentedTest.kt
│ ├── main
│ ├── AndroidManifest.xml
│ ├── ic_gpt_mobile-playstore.png
│ ├── kotlin
│ │ └── dev
│ │ │ └── chungjungsoo
│ │ │ └── gptmobile
│ │ │ ├── data
│ │ │ ├── ModelConstants.kt
│ │ │ ├── database
│ │ │ │ ├── ChatDatabase.kt
│ │ │ │ ├── dao
│ │ │ │ │ ├── ChatRoomDao.kt
│ │ │ │ │ └── MessageDao.kt
│ │ │ │ └── entity
│ │ │ │ │ ├── ChatRoom.kt
│ │ │ │ │ └── Message.kt
│ │ │ ├── datastore
│ │ │ │ ├── SettingDataSource.kt
│ │ │ │ └── SettingDataSourceImpl.kt
│ │ │ ├── dto
│ │ │ │ ├── APIModel.kt
│ │ │ │ ├── ApiState.kt
│ │ │ │ ├── Platform.kt
│ │ │ │ ├── ThemeSetting.kt
│ │ │ │ └── anthropic
│ │ │ │ │ ├── common
│ │ │ │ │ ├── ContentType.kt
│ │ │ │ │ ├── ImageContent.kt
│ │ │ │ │ ├── ImageSource.kt
│ │ │ │ │ ├── ImageSourceType.kt
│ │ │ │ │ ├── MediaType.kt
│ │ │ │ │ ├── MessageContent.kt
│ │ │ │ │ ├── MessageRole.kt
│ │ │ │ │ └── TextContent.kt
│ │ │ │ │ ├── request
│ │ │ │ │ ├── InputMessage.kt
│ │ │ │ │ ├── MessageRequest.kt
│ │ │ │ │ └── RequestMetadata.kt
│ │ │ │ │ └── response
│ │ │ │ │ ├── ContentBlock.kt
│ │ │ │ │ ├── ContentBlockType.kt
│ │ │ │ │ ├── ContentDeltaResponseChunk.kt
│ │ │ │ │ ├── ContentStartResponseChunk.kt
│ │ │ │ │ ├── ContentStopResponseChunk.kt
│ │ │ │ │ ├── ErrorDetail.kt
│ │ │ │ │ ├── ErrorResponseChunk.kt
│ │ │ │ │ ├── EventType.kt
│ │ │ │ │ ├── MessageDeltaResponseChunk.kt
│ │ │ │ │ ├── MessageResponse.kt
│ │ │ │ │ ├── MessageResponseChunk.kt
│ │ │ │ │ ├── MessageStartResponseChunk.kt
│ │ │ │ │ ├── MessageStopResponseChunk.kt
│ │ │ │ │ ├── PingResponseChunk.kt
│ │ │ │ │ ├── StopReason.kt
│ │ │ │ │ ├── StopReasonDelta.kt
│ │ │ │ │ ├── Usage.kt
│ │ │ │ │ └── UsageDelta.kt
│ │ │ ├── model
│ │ │ │ ├── ApiType.kt
│ │ │ │ ├── DynamicTheme.kt
│ │ │ │ └── ThemeMode.kt
│ │ │ ├── network
│ │ │ │ ├── AnthropicAPI.kt
│ │ │ │ ├── AnthropicAPIImpl.kt
│ │ │ │ └── NetworkClient.kt
│ │ │ └── repository
│ │ │ │ ├── ChatRepository.kt
│ │ │ │ ├── ChatRepositoryImpl.kt
│ │ │ │ ├── SettingRepository.kt
│ │ │ │ └── SettingRepositoryImpl.kt
│ │ │ ├── di
│ │ │ ├── ChatRepositoryModule.kt
│ │ │ ├── DataStoreModule.kt
│ │ │ ├── DatabaseModule.kt
│ │ │ ├── NetworkModule.kt
│ │ │ ├── SettingDataSourceModule.kt
│ │ │ └── SettingRepositoryModule.kt
│ │ │ ├── presentation
│ │ │ ├── GPTMobileApp.kt
│ │ │ ├── common
│ │ │ │ ├── NavigationGraph.kt
│ │ │ │ ├── PlatformCheckBoxItem.kt
│ │ │ │ ├── PrimaryLongButton.kt
│ │ │ │ ├── RadioItem.kt
│ │ │ │ ├── Route.kt
│ │ │ │ ├── SettingItem.kt
│ │ │ │ ├── ThemeSettingProvider.kt
│ │ │ │ ├── ThemeViewModel.kt
│ │ │ │ └── TokenInputField.kt
│ │ │ ├── icons
│ │ │ │ ├── Done.kt
│ │ │ │ └── GptMobileStartScreen.kt
│ │ │ ├── theme
│ │ │ │ ├── Color.kt
│ │ │ │ ├── Theme.kt
│ │ │ │ └── Type.kt
│ │ │ └── ui
│ │ │ │ ├── chat
│ │ │ │ ├── ChatBubble.kt
│ │ │ │ ├── ChatScreen.kt
│ │ │ │ └── ChatViewModel.kt
│ │ │ │ ├── home
│ │ │ │ ├── HomeScreen.kt
│ │ │ │ └── HomeViewModel.kt
│ │ │ │ ├── main
│ │ │ │ ├── MainActivity.kt
│ │ │ │ └── MainViewModel.kt
│ │ │ │ ├── setting
│ │ │ │ ├── AboutScreen.kt
│ │ │ │ ├── LicenseScreen.kt
│ │ │ │ ├── PlatformSettingDialogs.kt
│ │ │ │ ├── PlatformSettingScreen.kt
│ │ │ │ ├── SettingScreen.kt
│ │ │ │ └── SettingViewModel.kt
│ │ │ │ ├── setup
│ │ │ │ ├── SelectModelScreen.kt
│ │ │ │ ├── SelectPlatformScreen.kt
│ │ │ │ ├── SetupAPIUrlScreen.kt
│ │ │ │ ├── SetupAppBar.kt
│ │ │ │ ├── SetupCompleteScreen.kt
│ │ │ │ ├── SetupViewModel.kt
│ │ │ │ └── TokenInputScreen.kt
│ │ │ │ └── startscreen
│ │ │ │ └── StartScreen.kt
│ │ │ └── util
│ │ │ ├── DefaultHashMap.kt
│ │ │ ├── MapStringResources.kt
│ │ │ ├── PinnedExitUntilCollapsedScrollBehavior.kt
│ │ │ ├── ScrollStateSaver.kt
│ │ │ ├── StateFlowExtensions.kt
│ │ │ └── Strings.kt
│ └── res
│ │ ├── drawable
│ │ ├── ic_bug_report.xml
│ │ ├── ic_chart.xml
│ │ ├── ic_copy.xml
│ │ ├── ic_f_droid.xml
│ │ ├── ic_feedback.xml
│ │ ├── ic_github.xml
│ │ ├── ic_gpt_mobile_foreground.xml
│ │ ├── ic_gpt_mobile_monochrome_foreground.xml
│ │ ├── ic_info.xml
│ │ ├── ic_instructions.xml
│ │ ├── ic_key.xml
│ │ ├── ic_license.xml
│ │ ├── ic_link.xml
│ │ ├── ic_model.xml
│ │ ├── ic_play_store.xml
│ │ ├── ic_round_arrow_right.xml
│ │ ├── ic_rounded_chat.xml
│ │ ├── ic_send.xml
│ │ ├── ic_temperature.xml
│ │ └── splash_icon_inset.xml
│ │ ├── mipmap-anydpi
│ │ └── ic_gpt_mobile.xml
│ │ ├── resources.properties
│ │ ├── values-ko-rKR
│ │ └── strings.xml
│ │ ├── values-night
│ │ └── themes.xml
│ │ ├── values-ru
│ │ └── strings.xml
│ │ ├── values-tr
│ │ └── strings.xml
│ │ ├── values-zh-rCN
│ │ └── strings.xml
│ │ ├── values
│ │ ├── ic_gpt_mobile_background.xml
│ │ ├── strings.xml
│ │ └── themes.xml
│ │ └── xml
│ │ ├── backup_rules.xml
│ │ └── data_extraction_rules.xml
│ └── test
│ └── kotlin
│ └── dev
│ └── chungjungsoo
│ └── gptmobile
│ └── ExampleUnitTest.kt
├── build.gradle.kts
├── gradle.properties
├── gradle
├── libs.versions.toml
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── images
├── logo.png
└── screenshots.png
├── metadata
└── en-US
│ ├── full_description.txt
│ ├── images
│ ├── featureGraphic.png
│ ├── icon.png
│ └── phoneScreenshots
│ │ ├── 1.png
│ │ ├── 2.png
│ │ ├── 3.png
│ │ ├── 4.png
│ │ ├── 5.png
│ │ ├── 6.png
│ │ ├── 7.png
│ │ └── 8.png
│ ├── short_description.txt
│ └── title.txt
└── settings.gradle.kts
/.editorconfig:
--------------------------------------------------------------------------------
1 | [*.{kt,kts}]
2 | ktlint_code_style = android_studio
3 | end_of_line = lf
4 | ij_kotlin_allow_trailing_comma = false
5 | ij_kotlin_allow_trailing_comma_on_call_site = false
6 | ij_kotlin_imports_layout = *
7 | ij_kotlin_packages_to_use_import_on_demand = java.util.*,kotlinx.android.synthetic.**
8 | indent_size = 4
9 | indent_style = space
10 | insert_final_newline = true
11 | ktlint_chain_method_rule_force_multiline_when_chain_operator_count_greater_or_equal_than = unset
12 | ktlint_class_signature_rule_force_multiline_when_parameter_count_greater_or_equal_than = unset
13 | ktlint_code_style = android_studio
14 | ktlint_function_naming_ignore_when_annotated_with = [unset]
15 | ktlint_function_signature_body_expression_wrapping = default
16 | ktlint_function_signature_rule_force_multiline_when_parameter_count_greater_or_equal_than = unset
17 | ktlint_ignore_back_ticked_identifier = false
18 | max_line_length = off
19 | ktlint_function_naming_ignore_when_annotated_with=Composable
20 | compose_allowed_state_holder_names = .*ViewModel,.*Presenter,.*Component
21 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: Taewan-P
2 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: bug
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots or Screen Recording**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **Additional context**
27 | Add any other context about the problem here.
28 |
--------------------------------------------------------------------------------
/.github/workflows/debug-build.yml:
--------------------------------------------------------------------------------
1 | name: Debug build APK
2 |
3 | on:
4 | pull_request_target:
5 | branches:
6 | - "main"
7 | paths-ignore:
8 | - ".gitignore"
9 | - "**.md"
10 | - "LICENSE"
11 | - ".idea/**"
12 | - ".github/**"
13 | - ".editorconfig"
14 | - "images/**"
15 | - "metadata/**"
16 |
17 | jobs:
18 | run:
19 | name: Build debug APK
20 | runs-on: macos-14
21 |
22 | permissions:
23 | pull-requests: write
24 |
25 | steps:
26 | - uses: actions/checkout@v4
27 | with:
28 | ref: ${{ github.event.pull_request.head.ref }}
29 | repository: ${{ github.event.pull_request.head.repo.full_name }}
30 |
31 | - name: Setup JDK
32 | uses: actions/setup-java@v4
33 | with:
34 | distribution: "oracle"
35 | java-version: "17"
36 | cache: "gradle"
37 |
38 | - name: Build debug APK
39 | run: |
40 | ./gradlew assembleDebug
41 |
42 | - name: Upload debug APK
43 | uses: actions/upload-artifact@v4
44 | id: artifact-upload-step
45 | with:
46 | name: debug-${{ github.sha }}
47 | path: ${{ github.workspace }}/app/build/outputs/apk/debug/app-debug.apk
48 |
49 | - name: Comment APK Link
50 | uses: thollander/actions-comment-pull-request@v2
51 | with:
52 | comment_tag: apk
53 | message: |
54 | Build complete! Here's the debug APK: ${{ steps.artifact-upload-step.outputs.artifact-url }}
55 |
--------------------------------------------------------------------------------
/.github/workflows/ktlint.yml:
--------------------------------------------------------------------------------
1 | name: Kotlin Lint Check
2 |
3 | on:
4 | pull_request_target:
5 | branches:
6 | - "main"
7 | paths-ignore:
8 | - ".gitignore"
9 | - "**.md"
10 | - "LICENSE"
11 | - ".idea/**"
12 | - ".github/**"
13 | - ".editorconfig"
14 | - "images/**"
15 | - "metadata/**"
16 |
17 | jobs:
18 | ktlint:
19 | name: Check Kotlin Code Format
20 | runs-on: ubuntu-latest
21 |
22 | steps:
23 | - name: Clone repo
24 | uses: actions/checkout@v4
25 |
26 | - name: Run ktlint
27 | uses: ScaCap/action-ktlint@master
28 | with:
29 | filter_mode: file
30 | github_token: ${{ secrets.github_token }}
31 | reporter: github-check
32 |
--------------------------------------------------------------------------------
/.github/workflows/release-build.yml:
--------------------------------------------------------------------------------
1 | name: Generate Release Version
2 |
3 | on:
4 | workflow_dispatch:
5 |
6 | jobs:
7 | build:
8 | name: Build Release APK
9 | runs-on: macos-14
10 | steps:
11 | - uses: actions/checkout@v4
12 | with:
13 | fetch-depth: 0
14 |
15 | - name: Setup JDK
16 | uses: actions/setup-java@v4
17 | with:
18 | distribution: "temurin"
19 | java-version: "17"
20 | cache: "gradle"
21 |
22 | - name: Setup Android SDK
23 | uses: android-actions/setup-android@v3
24 |
25 | - name: Install Latest Build Tools
26 | run: |
27 | build_tools_list=$(sdkmanager --list | sed -n 's/.*\(build-tools;[0-9.][0-9.a-zA-Z-]*\).*/\1/p')
28 | stable_build_tools_list=$(echo "$build_tools_list" | grep -v "\-rc")
29 | latest_stable_version=$(echo "$stable_build_tools_list" | sort -V | tail -n 1)
30 | latest_version_number=$(echo $latest_stable_version | sed 's/.*;//')
31 | sdkmanager "$latest_stable_version"
32 | echo "$ANDROID_SDK_ROOT/build-tools/$latest_version_number" >> $GITHUB_PATH
33 |
34 | - name: Build Unsigned Release APK
35 | run: ./gradlew assembleRelease
36 |
37 | - name: Build Unsigned Release App Bundle
38 | run: ./gradlew bundleRelease
39 |
40 | - name: Generate Signing Key
41 | env:
42 | KEYSTORE_B64: ${{ secrets.APP_KEYSTORE }}
43 | run: |
44 | echo $KEYSTORE_B64 | base64 -d > keystore.jks
45 | cp keystore.jks ${{ github.workspace }}/app/build/outputs/bundle/release/keystore.jks
46 | working-directory: ${{ github.workspace }}/app/build/outputs/apk/release
47 |
48 | - name: Sign Release APK
49 | env:
50 | SIGNING_PASSWORD: ${{ secrets.KEY_PASSWORD }}
51 | run: |
52 | apksigner sign --ks keystore.jks --alignment-preserved true --ks-pass env:SIGNING_PASSWORD --out app-release.apk app-release-unsigned.apk
53 | working-directory: ${{ github.workspace }}/app/build/outputs/apk/release
54 |
55 | - name: Sign Release App Bundle
56 | env:
57 | SIGNING_KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
58 | SIGNING_PASSWORD: ${{ secrets.KEY_PASSWORD }}
59 | run: |
60 | jarsigner -verbose -sigalg SHA256withRSA -digestalg SHA-256 -keystore keystore.jks -keypass $SIGNING_PASSWORD -storepass $SIGNING_PASSWORD app-release.aab $SIGNING_KEY_ALIAS
61 | working-directory: ${{ github.workspace }}/app/build/outputs/bundle/release
62 |
63 | - name: Create Job Summary
64 | run: |
65 | unsigned_hash=$(md5 -q ./apk/release/app-release-unsigned.apk)
66 | signed_hash=$(md5 -q ./apk/release/app-release.apk)
67 | signed_aab_hash=$(md5 -q ./bundle/release/app-release.aab)
68 | certificate_hash=$(apksigner verify --print-certs ./apk/release/app-release.apk | grep "SHA-256" | awk -F': ' '{print $2}')
69 | echo "### Release Build Results" >> $GITHUB_STEP_SUMMARY
70 | echo "" >> $GITHUB_STEP_SUMMARY
71 | echo "**Unsigned APK md5**: \`$unsigned_hash\`" >> $GITHUB_STEP_SUMMARY
72 | echo "**Signed APK md5**: \`$signed_hash\`" >> $GITHUB_STEP_SUMMARY
73 | echo "**Signed AAB md5**: \`$signed_aab_hash\`" >> $GITHUB_STEP_SUMMARY
74 | echo "**Certificate SHA-256**: \`$certificate_hash\`" >> $GITHUB_STEP_SUMMARY
75 | working-directory: ${{ github.workspace }}/app/build/outputs
76 |
77 | - name: Upload Signed APK
78 | uses: actions/upload-artifact@v4
79 | id: artifact-upload-step
80 | with:
81 | name: release-${{ github.sha }}
82 | path: |
83 | ${{ github.workspace }}/app/build/outputs/apk/release/app-release.apk
84 | ${{ github.workspace }}/app/build/outputs/bundle/release/app-release.aab
85 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Gradle
2 | *.iml
3 | .gradle
4 |
5 | # Android
6 | /local.properties
7 |
8 | # Android Studio
9 | /.idea/caches
10 | /.idea/libraries
11 | /.idea/modules.xml
12 | /.idea/workspace.xml
13 | /.idea/navEditor.xml
14 | /.idea/assetWizardSettings.xml
15 | /.idea/inspectionProfiles/Project_Default.xml
16 | /.idea/appInsightsSettings.xml
17 | /.idea/deploymentTargetSelector.xml
18 | /.idea/other.xml
19 |
20 | # MacOS
21 | .DS_Store
22 |
23 | # Build
24 | /build
25 | /captures
26 | .externalNativeBuild
27 | .cxx
28 | app/release/**
29 | app/build/**
30 |
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 |
--------------------------------------------------------------------------------
/.idea/.name:
--------------------------------------------------------------------------------
1 | GPTMobile
--------------------------------------------------------------------------------
/.idea/compiler.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/gradle.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/.idea/kotlinc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/.idea/migrations.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/.idea/runConfigurations.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |

4 |
5 | # GPT Mobile
6 |
7 | ### Chat Assistant for Android that supports chatting with multiple models at once.
8 |
9 | [](https://github.com/Taewan-P/gpt_mobile/releases/)
10 | [](https://github.com/Taewan-P/gpt_mobile/releases/latest/)
11 |
12 |
13 |
14 |
15 | ## Screenshots
16 |
17 |
18 |
19 |

20 |
21 |
22 |
23 | ## Demos
24 |
25 |
26 | | | | |
27 | |------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------|
28 |
29 |
30 | ## Features
31 |
32 | - **Chat with multiple models at once**
33 | - Uses official APIs for each platforms
34 | - Supported platforms:
35 | - OpenAI GPT
36 | - Anthropic Claude
37 | - Google Gemini
38 | - Ollama
39 | - Can customize temperature, top p (Nucleus sampling), and system prompt
40 | - Custom API URLs, Custom Models are also supported
41 | - Local chat history
42 | - Chat history is **only saved locally**
43 | - Only sends to official API servers while chatting
44 | - [Material You](https://m3.material.io/) style UI, Icons
45 | - Supports dark mode, system dynamic theming **without Activity restart**
46 | - Per app language setting for Android 13+
47 | - 100% Kotlin, Jetpack Compose, Single Activity, [Modern App Architecture](https://developer.android.com/topic/architecture#modern-app-architecture) in Android developers documentation
48 |
49 |
50 | ## To be supported
51 |
52 | - Manual Languages Setting for Android 12 and below
53 | - More platforms
54 | - Image, file support for multimodal models
55 |
56 | If you have any feature requests, please open an issue.
57 |
58 |
59 | ## Downloads
60 |
61 | You can download the app from the following sites:
62 |
63 | [
](https://f-droid.org/packages/dev.chungjungsoo.gptmobile)
64 | [
](https://play.google.com/store/apps/details?id=dev.chungjungsoo.gptmobile&utm_source=github&utm_campaign=gh-readme)
65 | [
](https://github.com/Taewan-P/gpt_mobile/releases)
66 |
67 | Cross platform updates are supported. However, GitHub Releases will be the fastest track among the platforms since there is no verification/auditing process. (Probably 1 week difference?)
68 |
69 |
70 | ## Build
71 |
72 | 1. Clone repo
73 | 2. Open in Android Studio
74 | 3. Click `Run` or do Gradle build
75 |
76 |
77 | ## Star History
78 |
79 | [](https://star-history.com/#Taewan-P/gpt_mobile&Timeline)
80 |
81 |
82 | ## License
83 |
84 | See [LICENSE](./LICENSE) for details.
85 |
86 | [F-Droid Icon License](https://gitlab.com/fdroid/artwork/-/blob/master/fdroid-logo-2015/README.md)
87 |
88 |
--------------------------------------------------------------------------------
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/app/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.android.application)
3 | alias(libs.plugins.jetbrains.kotlin.android)
4 | alias(libs.plugins.android.hilt)
5 | alias(libs.plugins.compose.compiler)
6 | alias(libs.plugins.kotlin.ksp)
7 | alias(libs.plugins.kotlin.parcelize)
8 | alias(libs.plugins.auto.license)
9 | kotlin(libs.plugins.kotlin.serialization.get().pluginId).version(libs.versions.kotlin)
10 | }
11 |
12 | android {
13 | namespace = "dev.chungjungsoo.gptmobile"
14 | compileSdk = 34
15 |
16 | defaultConfig {
17 | applicationId = "dev.chungjungsoo.gptmobile"
18 | minSdk = 28
19 | targetSdk = 34
20 | versionCode = 11
21 | versionName = "0.5.3"
22 |
23 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
24 | vectorDrawables {
25 | useSupportLibrary = true
26 | }
27 | }
28 |
29 | androidResources {
30 | @file:Suppress("UnstableApiUsage") // Incubating class
31 | generateLocaleConfig = true
32 | }
33 |
34 | buildTypes {
35 | release {
36 | isMinifyEnabled = true
37 | @file:Suppress("UnstableApiUsage")
38 | vcsInfo.include = false
39 | proguardFiles(
40 | getDefaultProguardFile("proguard-android-optimize.txt"),
41 | "proguard-rules.pro"
42 | )
43 | }
44 | }
45 | compileOptions {
46 | sourceCompatibility = JavaVersion.VERSION_17
47 | targetCompatibility = JavaVersion.VERSION_17
48 | }
49 | kotlinOptions {
50 | jvmTarget = "17"
51 | }
52 | buildFeatures {
53 | compose = true
54 | }
55 | packaging {
56 | resources {
57 | excludes += "/META-INF/{AL2.0,LGPL2.1}"
58 | }
59 | }
60 | }
61 |
62 | dependencies {
63 | // Android
64 | implementation(libs.androidx.core.ktx)
65 | implementation(libs.androidx.lifecycle.runtime.ktx)
66 | implementation(libs.androidx.activity.compose)
67 | implementation(platform(libs.androidx.compose.bom))
68 | implementation(libs.androidx.compose.viewmodel)
69 | implementation(libs.androidx.ui)
70 | implementation(libs.androidx.ui.graphics)
71 | implementation(libs.androidx.ui.tooling.preview)
72 | implementation(libs.androidx.material3)
73 |
74 | // SplashScreen
75 | implementation(libs.splashscreen)
76 |
77 | // DataStore
78 | implementation(libs.androidx.datastore)
79 |
80 | // Dependency Injection
81 | implementation(libs.hilt)
82 | implementation(libs.androidx.lifecycle.runtime.compose.android)
83 | ksp(libs.hilt.compiler)
84 |
85 | // Gemini SDK
86 | implementation(libs.gemini)
87 |
88 | // Ktor
89 | implementation(libs.ktor.content.negotiation)
90 | implementation(libs.ktor.core)
91 | implementation(libs.ktor.engine)
92 | implementation(libs.ktor.logging)
93 | implementation(libs.ktor.serialization)
94 |
95 | // License page UI
96 | implementation(libs.auto.license.core)
97 | implementation(libs.auto.license.ui)
98 |
99 | // Markdown
100 | implementation(libs.compose.markdown)
101 |
102 | // Navigation
103 | implementation(libs.hilt.navigation)
104 | implementation(libs.androidx.navigation)
105 |
106 | // OpenAI (Ktor required)
107 | implementation(libs.openai)
108 |
109 | // Room
110 | implementation(libs.room)
111 | ksp(libs.room.compiler)
112 | implementation(libs.room.ktx)
113 |
114 | // Serialization
115 | implementation(libs.kotlin.serialization)
116 |
117 | // Test
118 | testImplementation(libs.junit)
119 | androidTestImplementation(libs.androidx.junit)
120 | androidTestImplementation(libs.androidx.espresso.core)
121 | androidTestImplementation(platform(libs.androidx.compose.bom))
122 | androidTestImplementation(libs.androidx.ui.test.junit4)
123 | debugImplementation(libs.androidx.ui.tooling)
124 | debugImplementation(libs.androidx.ui.test.manifest)
125 | }
126 |
127 | aboutLibraries {
128 | // Remove the "generated" timestamp to allow for reproducible builds
129 | excludeFields = arrayOf("generated")
130 | }
131 |
--------------------------------------------------------------------------------
/app/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/app/src/androidTest/kotlin/dev/chungjungsoo/gptmobile/ExampleInstrumentedTest.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile
2 |
3 | import androidx.test.ext.junit.runners.AndroidJUnit4
4 | import androidx.test.platform.app.InstrumentationRegistry
5 | import org.junit.Assert.assertEquals
6 | import org.junit.Test
7 | import org.junit.runner.RunWith
8 |
9 | /**
10 | * Instrumented test, which will execute on an Android device.
11 | *
12 | * See [testing documentation](http://d.android.com/tools/testing).
13 | */
14 | @RunWith(AndroidJUnit4::class)
15 | class ExampleInstrumentedTest {
16 | @Test
17 | fun useAppContext() {
18 | // Context of the app under test.
19 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext
20 | assertEquals("dev.chungjungsoo.gptmobile", appContext.packageName)
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
7 |
18 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/app/src/main/ic_gpt_mobile-playstore.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chocydar/GPTMobile/e91e61e30b195988f9bf83d0f48eedd38276f267/app/src/main/ic_gpt_mobile-playstore.png
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/data/ModelConstants.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.data
2 |
3 | import dev.chungjungsoo.gptmobile.data.model.ApiType
4 |
5 | object ModelConstants {
6 | // LinkedHashSet should be used to guarantee item order
7 | val openaiModels = linkedSetOf("gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-4")
8 | val anthropicModels = linkedSetOf("claude-3-5-sonnet-20240620", "claude-3-opus-20240229", "claude-3-sonnet-20240229", "claude-3-haiku-20240307")
9 | val googleModels = linkedSetOf("gemini-1.5-pro-latest", "gemini-1.5-flash-latest", "gemini-1.0-pro")
10 | val ollamaModels = linkedSetOf()
11 |
12 | const val OPENAI_API_URL = "https://api.openai.com/v1/"
13 | const val ANTHROPIC_API_URL = "https://api.anthropic.com/"
14 | const val GOOGLE_API_URL = "https://generativelanguage.googleapis.com"
15 |
16 | fun getDefaultAPIUrl(apiType: ApiType) = when (apiType) {
17 | ApiType.OPENAI -> OPENAI_API_URL
18 | ApiType.ANTHROPIC -> ANTHROPIC_API_URL
19 | ApiType.GOOGLE -> GOOGLE_API_URL
20 | ApiType.OLLAMA -> ""
21 | }
22 |
23 | const val ANTHROPIC_MAXIMUM_TOKEN = 4096
24 |
25 | const val OPENAI_PROMPT =
26 | "You are a helpful, clever, and very friendly assistant. " +
27 | "You are familiar with various languages in the world. " +
28 | "You are to answer my questions precisely. "
29 |
30 | const val DEFAULT_PROMPT = "Your task is to answer my questions precisely."
31 | }
32 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/data/database/ChatDatabase.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.data.database
2 |
3 | import androidx.room.Database
4 | import androidx.room.RoomDatabase
5 | import androidx.room.TypeConverters
6 | import dev.chungjungsoo.gptmobile.data.database.dao.ChatRoomDao
7 | import dev.chungjungsoo.gptmobile.data.database.dao.MessageDao
8 | import dev.chungjungsoo.gptmobile.data.database.entity.APITypeConverter
9 | import dev.chungjungsoo.gptmobile.data.database.entity.ChatRoom
10 | import dev.chungjungsoo.gptmobile.data.database.entity.Message
11 |
12 | @Database(entities = [ChatRoom::class, Message::class], version = 1)
13 | @TypeConverters(APITypeConverter::class)
14 | abstract class ChatDatabase : RoomDatabase() {
15 |
16 | abstract fun chatRoomDao(): ChatRoomDao
17 | abstract fun messageDao(): MessageDao
18 | }
19 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/data/database/dao/ChatRoomDao.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.data.database.dao
2 |
3 | import androidx.room.Dao
4 | import androidx.room.Delete
5 | import androidx.room.Insert
6 | import androidx.room.Query
7 | import androidx.room.Update
8 | import dev.chungjungsoo.gptmobile.data.database.entity.ChatRoom
9 |
10 | @Dao
11 | interface ChatRoomDao {
12 |
13 | @Query("SELECT * FROM chats ORDER BY created_at DESC")
14 | suspend fun getChatRooms(): List
15 |
16 | @Insert
17 | suspend fun addChatRoom(chatRoom: ChatRoom): Long
18 |
19 | @Update
20 | suspend fun editChatRoom(chatRoom: ChatRoom)
21 |
22 | @Delete
23 | suspend fun deleteChatRooms(vararg chatRooms: ChatRoom)
24 | }
25 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/data/database/dao/MessageDao.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.data.database.dao
2 |
3 | import androidx.room.Dao
4 | import androidx.room.Delete
5 | import androidx.room.Insert
6 | import androidx.room.Query
7 | import androidx.room.Update
8 | import dev.chungjungsoo.gptmobile.data.database.entity.Message
9 |
10 | @Dao
11 | interface MessageDao {
12 |
13 | @Query("SELECT * FROM messages WHERE chat_id=:chatInt")
14 | suspend fun loadMessages(chatInt: Int): List
15 |
16 | @Insert
17 | suspend fun addMessages(vararg messages: Message)
18 |
19 | @Update
20 | suspend fun editMessages(vararg message: Message)
21 |
22 | @Delete
23 | suspend fun deleteMessages(vararg message: Message)
24 | }
25 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/data/database/entity/ChatRoom.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.data.database.entity
2 |
3 | import android.os.Parcelable
4 | import androidx.room.ColumnInfo
5 | import androidx.room.Entity
6 | import androidx.room.PrimaryKey
7 | import androidx.room.TypeConverter
8 | import dev.chungjungsoo.gptmobile.data.model.ApiType
9 | import kotlinx.parcelize.Parcelize
10 |
11 | @Parcelize
12 | @Entity(tableName = "chats")
13 | data class ChatRoom(
14 | @PrimaryKey(autoGenerate = true)
15 | @ColumnInfo(name = "chat_id")
16 | val id: Int = 0,
17 |
18 | @ColumnInfo(name = "title")
19 | val title: String,
20 |
21 | @ColumnInfo(name = "enabled_platform")
22 | val enabledPlatform: List,
23 |
24 | @ColumnInfo(name = "created_at")
25 | val createdAt: Long = System.currentTimeMillis() / 1000
26 | ) : Parcelable
27 |
28 | class APITypeConverter {
29 | @TypeConverter
30 | fun fromString(value: String): List {
31 | val splitted = value.split(',')
32 |
33 | return splitted.map { s -> ApiType.valueOf(s) }
34 | }
35 |
36 | @TypeConverter
37 | fun fromList(value: List): String = value.joinToString(",") { v -> v.name }
38 | }
39 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/data/database/entity/Message.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.data.database.entity
2 |
3 | import androidx.room.ColumnInfo
4 | import androidx.room.Entity
5 | import androidx.room.ForeignKey
6 | import androidx.room.PrimaryKey
7 | import dev.chungjungsoo.gptmobile.data.model.ApiType
8 |
9 | @Entity(
10 | tableName = "messages",
11 | foreignKeys = [
12 | ForeignKey(
13 | entity = ChatRoom::class,
14 | parentColumns = ["chat_id"],
15 | childColumns = ["chat_id"],
16 | onDelete = ForeignKey.CASCADE
17 | )
18 | ]
19 | )
20 | data class Message(
21 | @PrimaryKey(autoGenerate = true)
22 | @ColumnInfo("message_id")
23 | val id: Int = 0,
24 |
25 | @ColumnInfo(name = "chat_id")
26 | val chatId: Int = 0,
27 |
28 | @ColumnInfo(name = "content")
29 | val content: String,
30 |
31 | @ColumnInfo(name = "image_data")
32 | val imageData: String? = null,
33 |
34 | @ColumnInfo(name = "linked_message_id")
35 | val linkedMessageId: Int = 0,
36 |
37 | @ColumnInfo(name = "platform_type")
38 | val platformType: ApiType?,
39 |
40 | @ColumnInfo(name = "created_at")
41 | val createdAt: Long = System.currentTimeMillis() / 1000
42 | )
43 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/data/datastore/SettingDataSource.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.data.datastore
2 |
3 | import dev.chungjungsoo.gptmobile.data.model.ApiType
4 | import dev.chungjungsoo.gptmobile.data.model.DynamicTheme
5 | import dev.chungjungsoo.gptmobile.data.model.ThemeMode
6 |
7 | interface SettingDataSource {
8 | suspend fun updateDynamicTheme(theme: DynamicTheme)
9 | suspend fun updateThemeMode(themeMode: ThemeMode)
10 | suspend fun updateStatus(apiType: ApiType, status: Boolean)
11 | suspend fun updateAPIUrl(apiType: ApiType, url: String)
12 | suspend fun updateToken(apiType: ApiType, token: String)
13 | suspend fun updateModel(apiType: ApiType, model: String)
14 | suspend fun updateTemperature(apiType: ApiType, temperature: Float)
15 | suspend fun updateTopP(apiType: ApiType, topP: Float)
16 | suspend fun updateSystemPrompt(apiType: ApiType, prompt: String)
17 | suspend fun getDynamicTheme(): DynamicTheme?
18 | suspend fun getThemeMode(): ThemeMode?
19 | suspend fun getStatus(apiType: ApiType): Boolean?
20 | suspend fun getAPIUrl(apiType: ApiType): String?
21 | suspend fun getToken(apiType: ApiType): String?
22 | suspend fun getModel(apiType: ApiType): String?
23 | suspend fun getTemperature(apiType: ApiType): Float?
24 | suspend fun getTopP(apiType: ApiType): Float?
25 | suspend fun getSystemPrompt(apiType: ApiType): String?
26 | }
27 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/data/datastore/SettingDataSourceImpl.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.data.datastore
2 |
3 | import androidx.datastore.core.DataStore
4 | import androidx.datastore.preferences.core.Preferences
5 | import androidx.datastore.preferences.core.booleanPreferencesKey
6 | import androidx.datastore.preferences.core.edit
7 | import androidx.datastore.preferences.core.floatPreferencesKey
8 | import androidx.datastore.preferences.core.intPreferencesKey
9 | import androidx.datastore.preferences.core.stringPreferencesKey
10 | import dev.chungjungsoo.gptmobile.data.model.ApiType
11 | import dev.chungjungsoo.gptmobile.data.model.DynamicTheme
12 | import dev.chungjungsoo.gptmobile.data.model.ThemeMode
13 | import javax.inject.Inject
14 | import kotlinx.coroutines.flow.first
15 | import kotlinx.coroutines.flow.map
16 |
17 | class SettingDataSourceImpl @Inject constructor(
18 | private val dataStore: DataStore
19 | ) : SettingDataSource {
20 | private val apiStatusMap = mapOf(
21 | ApiType.OPENAI to booleanPreferencesKey("openai_status"),
22 | ApiType.ANTHROPIC to booleanPreferencesKey("anthropic_status"),
23 | ApiType.GOOGLE to booleanPreferencesKey("google_status"),
24 | ApiType.OLLAMA to booleanPreferencesKey("ollama_status")
25 | )
26 | private val apiUrlMap = mapOf(
27 | ApiType.OPENAI to stringPreferencesKey("openai_url"),
28 | ApiType.ANTHROPIC to stringPreferencesKey("anthropic_url"),
29 | ApiType.GOOGLE to stringPreferencesKey("google_url"),
30 | ApiType.OLLAMA to stringPreferencesKey("ollama_url")
31 | )
32 | private val apiTokenMap = mapOf(
33 | ApiType.OPENAI to stringPreferencesKey("openai_token"),
34 | ApiType.ANTHROPIC to stringPreferencesKey("anthropic_token"),
35 | ApiType.GOOGLE to stringPreferencesKey("google_token"),
36 | ApiType.OLLAMA to stringPreferencesKey("ollama_token")
37 | )
38 | private val apiModelMap = mapOf(
39 | ApiType.OPENAI to stringPreferencesKey("openai_model"),
40 | ApiType.ANTHROPIC to stringPreferencesKey("anthropic_model"),
41 | ApiType.GOOGLE to stringPreferencesKey("google_model"),
42 | ApiType.OLLAMA to stringPreferencesKey("ollama_model")
43 | )
44 | private val apiTemperatureMap = mapOf(
45 | ApiType.OPENAI to floatPreferencesKey("openai_temperature"),
46 | ApiType.ANTHROPIC to floatPreferencesKey("anthropic_temperature"),
47 | ApiType.GOOGLE to floatPreferencesKey("google_temperature"),
48 | ApiType.OLLAMA to floatPreferencesKey("ollama_temperature")
49 | )
50 | private val apiTopPMap = mapOf(
51 | ApiType.OPENAI to floatPreferencesKey("openai_top_p"),
52 | ApiType.ANTHROPIC to floatPreferencesKey("anthropic_top_p"),
53 | ApiType.GOOGLE to floatPreferencesKey("google_top_p"),
54 | ApiType.OLLAMA to floatPreferencesKey("ollama_top_p")
55 | )
56 | private val apiSystemPromptMap = mapOf(
57 | ApiType.OPENAI to stringPreferencesKey("openai_system_prompt"),
58 | ApiType.ANTHROPIC to stringPreferencesKey("anthropic_system_prompt"),
59 | ApiType.GOOGLE to stringPreferencesKey("google_system_prompt"),
60 | ApiType.OLLAMA to stringPreferencesKey("ollama_system_prompt")
61 | )
62 | private val dynamicThemeKey = intPreferencesKey("dynamic_mode")
63 | private val themeModeKey = intPreferencesKey("theme_mode")
64 |
65 | override suspend fun updateDynamicTheme(theme: DynamicTheme) {
66 | dataStore.edit { pref ->
67 | pref[dynamicThemeKey] = theme.ordinal
68 | }
69 | }
70 |
71 | override suspend fun updateThemeMode(themeMode: ThemeMode) {
72 | dataStore.edit { pref ->
73 | pref[themeModeKey] = themeMode.ordinal
74 | }
75 | }
76 |
77 | override suspend fun updateStatus(apiType: ApiType, status: Boolean) {
78 | dataStore.edit { pref ->
79 | pref[apiStatusMap[apiType]!!] = status
80 | }
81 | }
82 |
83 | override suspend fun updateAPIUrl(apiType: ApiType, url: String) {
84 | dataStore.edit { pref ->
85 | pref[apiUrlMap[apiType]!!] = url
86 | }
87 | }
88 |
89 | override suspend fun updateToken(apiType: ApiType, token: String) {
90 | dataStore.edit { pref ->
91 | pref[apiTokenMap[apiType]!!] = token
92 | }
93 | }
94 |
95 | override suspend fun updateModel(apiType: ApiType, model: String) {
96 | dataStore.edit { pref ->
97 | pref[apiModelMap[apiType]!!] = model
98 | }
99 | }
100 |
101 | override suspend fun updateTemperature(apiType: ApiType, temperature: Float) {
102 | dataStore.edit { pref ->
103 | pref[apiTemperatureMap[apiType]!!] = temperature
104 | }
105 | }
106 |
107 | override suspend fun updateTopP(apiType: ApiType, topP: Float) {
108 | dataStore.edit { pref ->
109 | pref[apiTopPMap[apiType]!!] = topP
110 | }
111 | }
112 |
113 | override suspend fun updateSystemPrompt(apiType: ApiType, prompt: String) {
114 | dataStore.edit { pref ->
115 | pref[apiSystemPromptMap[apiType]!!] = prompt
116 | }
117 | }
118 |
119 | override suspend fun getDynamicTheme(): DynamicTheme? {
120 | val mode = dataStore.data.map { pref ->
121 | pref[dynamicThemeKey]
122 | }.first() ?: return null
123 |
124 | return DynamicTheme.getByValue(mode)
125 | }
126 |
127 | override suspend fun getThemeMode(): ThemeMode? {
128 | val mode = dataStore.data.map { pref ->
129 | pref[themeModeKey]
130 | }.first() ?: return null
131 |
132 | return ThemeMode.getByValue(mode)
133 | }
134 |
135 | override suspend fun getStatus(apiType: ApiType): Boolean? = dataStore.data.map { pref ->
136 | pref[apiStatusMap[apiType]!!]
137 | }.first()
138 |
139 | override suspend fun getAPIUrl(apiType: ApiType): String? = dataStore.data.map { pref ->
140 | pref[apiUrlMap[apiType]!!]
141 | }.first()
142 |
143 | override suspend fun getToken(apiType: ApiType): String? = dataStore.data.map { pref ->
144 | pref[apiTokenMap[apiType]!!]
145 | }.first()
146 |
147 | override suspend fun getModel(apiType: ApiType): String? = dataStore.data.map { pref ->
148 | pref[apiModelMap[apiType]!!]
149 | }.first()
150 |
151 | override suspend fun getTemperature(apiType: ApiType): Float? = dataStore.data.map { pref ->
152 | pref[apiTemperatureMap[apiType]!!]
153 | }.first()
154 |
155 | override suspend fun getTopP(apiType: ApiType): Float? = dataStore.data.map { pref ->
156 | pref[apiTopPMap[apiType]!!]
157 | }.first()
158 |
159 | override suspend fun getSystemPrompt(apiType: ApiType): String? = dataStore.data.map { pref ->
160 | pref[apiSystemPromptMap[apiType]!!]
161 | }.first()
162 | }
163 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/data/dto/APIModel.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.data.dto
2 |
3 | data class APIModel(
4 | val name: String,
5 | val description: String,
6 | val aliasValue: String
7 | )
8 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/data/dto/ApiState.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.data.dto
2 |
3 | sealed class ApiState {
4 | data object Loading : ApiState()
5 | data class Success(val textChunk: String) : ApiState()
6 | data class Error(val message: String) : ApiState()
7 | data object Done : ApiState()
8 | }
9 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/data/dto/Platform.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.data.dto
2 |
3 | import dev.chungjungsoo.gptmobile.data.ModelConstants.getDefaultAPIUrl
4 | import dev.chungjungsoo.gptmobile.data.model.ApiType
5 |
6 | data class Platform(
7 | val name: ApiType,
8 | val selected: Boolean = false,
9 | val enabled: Boolean = false,
10 | val apiUrl: String = getDefaultAPIUrl(name),
11 | val token: String? = null,
12 | val model: String? = null,
13 | val temperature: Float? = null,
14 | val topP: Float? = null,
15 | val systemPrompt: String? = null
16 | )
17 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/data/dto/ThemeSetting.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.data.dto
2 |
3 | import dev.chungjungsoo.gptmobile.data.model.DynamicTheme
4 | import dev.chungjungsoo.gptmobile.data.model.ThemeMode
5 |
6 | data class ThemeSetting(
7 | val dynamicTheme: DynamicTheme = DynamicTheme.OFF,
8 | val themeMode: ThemeMode = ThemeMode.SYSTEM
9 | )
10 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/data/dto/anthropic/common/ContentType.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.data.dto.anthropic.common
2 |
3 | import kotlinx.serialization.SerialName
4 | import kotlinx.serialization.Serializable
5 |
6 | @Serializable
7 | enum class ContentType {
8 |
9 | @SerialName("text")
10 | TEXT,
11 |
12 | @SerialName("image")
13 | IMAGE
14 | }
15 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/data/dto/anthropic/common/ImageContent.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.data.dto.anthropic.common
2 |
3 | import kotlinx.serialization.SerialName
4 | import kotlinx.serialization.Serializable
5 |
6 | @Serializable
7 | @SerialName("image")
8 | data class ImageContent(
9 |
10 | @SerialName("source")
11 | val source: ImageSource
12 | ) : MessageContent()
13 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/data/dto/anthropic/common/ImageSource.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.data.dto.anthropic.common
2 |
3 | import kotlinx.serialization.SerialName
4 | import kotlinx.serialization.Serializable
5 |
6 | @Serializable
7 | data class ImageSource(
8 | @SerialName("type")
9 | val type: ImageSourceType,
10 |
11 | @SerialName("media_type")
12 | val mediaType: MediaType,
13 |
14 | @SerialName("data")
15 | val data: String
16 | )
17 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/data/dto/anthropic/common/ImageSourceType.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.data.dto.anthropic.common
2 |
3 | import kotlinx.serialization.SerialName
4 | import kotlinx.serialization.Serializable
5 |
6 | @Serializable
7 | enum class ImageSourceType {
8 |
9 | @SerialName("base64")
10 | BASE64
11 | }
12 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/data/dto/anthropic/common/MediaType.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.data.dto.anthropic.common
2 |
3 | import kotlinx.serialization.SerialName
4 | import kotlinx.serialization.Serializable
5 |
6 | @Serializable
7 | enum class MediaType {
8 |
9 | @SerialName("image/jpeg")
10 | JPEG,
11 |
12 | @SerialName("image/png")
13 | PNG,
14 |
15 | @SerialName("image/gif")
16 | GIF,
17 |
18 | @SerialName("image/webp")
19 | WEBP
20 | }
21 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/data/dto/anthropic/common/MessageContent.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.data.dto.anthropic.common
2 |
3 | import kotlinx.serialization.Serializable
4 |
5 | @Serializable
6 | sealed class MessageContent
7 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/data/dto/anthropic/common/MessageRole.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.data.dto.anthropic.common
2 |
3 | import kotlinx.serialization.SerialName
4 | import kotlinx.serialization.Serializable
5 |
6 | @Serializable
7 | enum class MessageRole {
8 |
9 | @SerialName("user")
10 | USER,
11 |
12 | @SerialName("assistant")
13 | ASSISTANT
14 | }
15 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/data/dto/anthropic/common/TextContent.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.data.dto.anthropic.common
2 |
3 | import kotlinx.serialization.SerialName
4 | import kotlinx.serialization.Serializable
5 |
6 | @Serializable
7 | @SerialName("text")
8 | data class TextContent(
9 |
10 | @SerialName("text")
11 | val text: String
12 | ) : MessageContent()
13 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/data/dto/anthropic/request/InputMessage.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.data.dto.anthropic.request
2 |
3 | import dev.chungjungsoo.gptmobile.data.dto.anthropic.common.MessageContent
4 | import dev.chungjungsoo.gptmobile.data.dto.anthropic.common.MessageRole
5 | import kotlinx.serialization.SerialName
6 | import kotlinx.serialization.Serializable
7 |
8 | @Serializable
9 | data class InputMessage(
10 | @SerialName("role")
11 | val role: MessageRole,
12 |
13 | @SerialName("content")
14 | val content: List
15 | )
16 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/data/dto/anthropic/request/MessageRequest.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.data.dto.anthropic.request
2 |
3 | import kotlinx.serialization.EncodeDefault
4 | import kotlinx.serialization.ExperimentalSerializationApi
5 | import kotlinx.serialization.SerialName
6 | import kotlinx.serialization.Serializable
7 |
8 | /**
9 | When certain value is used in the future, use @EncodeDefault or remove default values
10 | */
11 |
12 | @OptIn(ExperimentalSerializationApi::class)
13 | @Serializable
14 | data class MessageRequest(
15 | @SerialName("model")
16 | val model: String,
17 |
18 | @SerialName("messages")
19 | val messages: List,
20 |
21 | @SerialName("max_tokens")
22 | val maxTokens: Int,
23 |
24 | @SerialName("metadata")
25 | @EncodeDefault(EncodeDefault.Mode.NEVER)
26 | val metadata: RequestMetadata? = null,
27 |
28 | @SerialName("stop_sequences")
29 | @EncodeDefault(EncodeDefault.Mode.NEVER)
30 | val stopSequences: List? = null,
31 |
32 | @SerialName("stream")
33 | @EncodeDefault(EncodeDefault.Mode.NEVER)
34 | val stream: Boolean = false,
35 |
36 | @SerialName("system")
37 | @EncodeDefault(EncodeDefault.Mode.NEVER)
38 | val systemPrompt: String? = null,
39 |
40 | @SerialName("temperature")
41 | @EncodeDefault(EncodeDefault.Mode.NEVER)
42 | val temperature: Float? = null,
43 |
44 | @SerialName("top_k")
45 | @EncodeDefault(EncodeDefault.Mode.NEVER)
46 | val topK: Int? = null,
47 |
48 | @SerialName("top_p")
49 | @EncodeDefault(EncodeDefault.Mode.NEVER)
50 | val topP: Float? = null
51 | )
52 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/data/dto/anthropic/request/RequestMetadata.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.data.dto.anthropic.request
2 |
3 | import kotlinx.serialization.SerialName
4 | import kotlinx.serialization.Serializable
5 |
6 | @Serializable
7 | data class RequestMetadata(
8 | @SerialName("user_id")
9 | val userId: String? = null
10 | )
11 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/data/dto/anthropic/response/ContentBlock.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.data.dto.anthropic.response
2 |
3 | import kotlinx.serialization.SerialName
4 | import kotlinx.serialization.Serializable
5 |
6 | @Serializable
7 | data class ContentBlock(
8 |
9 | @SerialName("type")
10 | val type: ContentBlockType,
11 |
12 | @SerialName("text")
13 | val text: String
14 | )
15 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/data/dto/anthropic/response/ContentBlockType.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.data.dto.anthropic.response
2 |
3 | import kotlinx.serialization.SerialName
4 | import kotlinx.serialization.Serializable
5 |
6 | @Serializable
7 | enum class ContentBlockType {
8 |
9 | @SerialName("text")
10 | TEXT,
11 |
12 | @SerialName("text_delta")
13 | DELTA
14 | }
15 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/data/dto/anthropic/response/ContentDeltaResponseChunk.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.data.dto.anthropic.response
2 |
3 | import kotlinx.serialization.SerialName
4 | import kotlinx.serialization.Serializable
5 |
6 | @Serializable
7 | @SerialName("content_block_delta")
8 | data class ContentDeltaResponseChunk(
9 |
10 | @SerialName("index")
11 | val index: Int,
12 |
13 | @SerialName("delta")
14 | val delta: ContentBlock
15 | ) : MessageResponseChunk()
16 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/data/dto/anthropic/response/ContentStartResponseChunk.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.data.dto.anthropic.response
2 |
3 | import kotlinx.serialization.SerialName
4 | import kotlinx.serialization.Serializable
5 |
6 | @Serializable
7 | @SerialName("content_block_start")
8 | data class ContentStartResponseChunk(
9 |
10 | @SerialName("index")
11 | val index: Int,
12 |
13 | @SerialName("content_block")
14 | val contentBlock: ContentBlock
15 | ) : MessageResponseChunk()
16 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/data/dto/anthropic/response/ContentStopResponseChunk.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.data.dto.anthropic.response
2 |
3 | import kotlinx.serialization.SerialName
4 | import kotlinx.serialization.Serializable
5 |
6 | @Serializable
7 | @SerialName("content_block_stop")
8 | data class ContentStopResponseChunk(
9 |
10 | @SerialName("index")
11 | val index: Int
12 | ) : MessageResponseChunk()
13 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/data/dto/anthropic/response/ErrorDetail.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.data.dto.anthropic.response
2 |
3 | import kotlinx.serialization.SerialName
4 | import kotlinx.serialization.Serializable
5 |
6 | @Serializable
7 | data class ErrorDetail(
8 |
9 | @SerialName("type")
10 | val type: String,
11 |
12 | @SerialName("message")
13 | val message: String
14 | )
15 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/data/dto/anthropic/response/ErrorResponseChunk.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.data.dto.anthropic.response
2 |
3 | import kotlinx.serialization.SerialName
4 | import kotlinx.serialization.Serializable
5 |
6 | @Serializable
7 | @SerialName("error")
8 | data class ErrorResponseChunk(
9 |
10 | @SerialName("error")
11 | val error: ErrorDetail
12 | ) : MessageResponseChunk()
13 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/data/dto/anthropic/response/EventType.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.data.dto.anthropic.response
2 |
3 | import kotlinx.serialization.SerialName
4 | import kotlinx.serialization.Serializable
5 |
6 | @Serializable
7 | enum class EventType {
8 |
9 | @SerialName("message_start")
10 | MESSAGE_START,
11 |
12 | @SerialName("content_block_start")
13 | CONTENT_START,
14 |
15 | @SerialName("content_block_delta")
16 | CONTENT_DELTA,
17 |
18 | @SerialName("content_block_stop")
19 | CONTENT_STOP,
20 |
21 | @SerialName("message_delta")
22 | MESSAGE_DELTA,
23 |
24 | @SerialName("message_stop")
25 | MESSAGE_STOP,
26 |
27 | @SerialName("ping")
28 | PING,
29 |
30 | @SerialName("error")
31 | ERROR
32 | }
33 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/data/dto/anthropic/response/MessageDeltaResponseChunk.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.data.dto.anthropic.response
2 |
3 | import kotlinx.serialization.SerialName
4 | import kotlinx.serialization.Serializable
5 |
6 | @Serializable
7 | @SerialName("message_delta")
8 | data class MessageDeltaResponseChunk(
9 |
10 | @SerialName("delta")
11 | val delta: StopReasonDelta,
12 |
13 | @SerialName("usage")
14 | val usage: UsageDelta
15 | ) : MessageResponseChunk()
16 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/data/dto/anthropic/response/MessageResponse.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.data.dto.anthropic.response
2 |
3 | import dev.chungjungsoo.gptmobile.data.dto.anthropic.common.MessageRole
4 | import dev.chungjungsoo.gptmobile.data.dto.anthropic.common.TextContent
5 | import kotlinx.serialization.SerialName
6 | import kotlinx.serialization.Serializable
7 |
8 | @Serializable
9 | data class MessageResponse(
10 | @SerialName("id")
11 | val id: String,
12 |
13 | @SerialName("type")
14 | val type: String = "message",
15 |
16 | @SerialName("role")
17 | val role: MessageRole = MessageRole.ASSISTANT,
18 |
19 | @SerialName("content")
20 | val content: List,
21 |
22 | @SerialName("model")
23 | val model: String,
24 |
25 | @SerialName("stop_reason")
26 | val stopReason: StopReason? = null,
27 |
28 | @SerialName("stop_sequence")
29 | val stopSequence: String? = null,
30 |
31 | @SerialName("usage")
32 | val usage: Usage
33 | )
34 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/data/dto/anthropic/response/MessageResponseChunk.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.data.dto.anthropic.response
2 |
3 | import kotlinx.serialization.Serializable
4 |
5 | @Serializable
6 | sealed class MessageResponseChunk
7 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/data/dto/anthropic/response/MessageStartResponseChunk.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.data.dto.anthropic.response
2 |
3 | import kotlinx.serialization.SerialName
4 | import kotlinx.serialization.Serializable
5 |
6 | @Serializable
7 | @SerialName("message_start")
8 | data class MessageStartResponseChunk(
9 |
10 | @SerialName("message")
11 | val message: MessageResponse
12 | ) : MessageResponseChunk()
13 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/data/dto/anthropic/response/MessageStopResponseChunk.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.data.dto.anthropic.response
2 |
3 | import kotlinx.serialization.SerialName
4 | import kotlinx.serialization.Serializable
5 |
6 | @Serializable
7 | @SerialName("message_stop")
8 | data object MessageStopResponseChunk : MessageResponseChunk()
9 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/data/dto/anthropic/response/PingResponseChunk.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.data.dto.anthropic.response
2 |
3 | import kotlinx.serialization.SerialName
4 | import kotlinx.serialization.Serializable
5 |
6 | @Serializable
7 | @SerialName("ping")
8 | data object PingResponseChunk : MessageResponseChunk()
9 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/data/dto/anthropic/response/StopReason.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.data.dto.anthropic.response
2 |
3 | import kotlinx.serialization.SerialName
4 | import kotlinx.serialization.Serializable
5 |
6 | @Serializable
7 | enum class StopReason {
8 |
9 | @SerialName("end_turn")
10 | END_TURN,
11 |
12 | @SerialName("max_tokens")
13 | MAX_TOKENS,
14 |
15 | @SerialName("stop_sequence")
16 | STOP_SEQUENCE,
17 |
18 | @SerialName("tool_use")
19 | TOOL_USE
20 | }
21 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/data/dto/anthropic/response/StopReasonDelta.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.data.dto.anthropic.response
2 |
3 | import kotlinx.serialization.SerialName
4 | import kotlinx.serialization.Serializable
5 |
6 | @Serializable
7 | data class StopReasonDelta(
8 |
9 | @SerialName("stop_reason")
10 | val stopReason: StopReason,
11 |
12 | @SerialName("stop_sequence")
13 | val stopSequence: String? = null
14 | )
15 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/data/dto/anthropic/response/Usage.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.data.dto.anthropic.response
2 |
3 | import kotlinx.serialization.SerialName
4 | import kotlinx.serialization.Serializable
5 |
6 | @Serializable
7 | data class Usage(
8 |
9 | @SerialName("input_tokens")
10 | val inputTokens: Int,
11 |
12 | @SerialName("output_tokens")
13 | val outputTokens: Int
14 | )
15 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/data/dto/anthropic/response/UsageDelta.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.data.dto.anthropic.response
2 |
3 | import kotlinx.serialization.SerialName
4 | import kotlinx.serialization.Serializable
5 |
6 | @Serializable
7 | data class UsageDelta(
8 |
9 | @SerialName("output_tokens")
10 | val outputTokens: Int
11 | )
12 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/data/model/ApiType.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.data.model
2 |
3 | enum class ApiType {
4 | OPENAI,
5 | ANTHROPIC,
6 | GOOGLE,
7 | OLLAMA
8 | }
9 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/data/model/DynamicTheme.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.data.model
2 |
3 | enum class DynamicTheme {
4 | ON,
5 | OFF;
6 |
7 | companion object {
8 | fun getByValue(value: Int) = entries.firstOrNull { it.ordinal == value }
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/data/model/ThemeMode.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.data.model
2 |
3 | enum class ThemeMode {
4 | SYSTEM,
5 | DARK,
6 | LIGHT;
7 |
8 | companion object {
9 | fun getByValue(value: Int) = entries.firstOrNull { it.ordinal == value }
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/data/network/AnthropicAPI.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.data.network
2 |
3 | import dev.chungjungsoo.gptmobile.data.dto.anthropic.request.MessageRequest
4 | import dev.chungjungsoo.gptmobile.data.dto.anthropic.response.MessageResponseChunk
5 | import kotlinx.coroutines.flow.Flow
6 |
7 | interface AnthropicAPI {
8 | fun setToken(token: String?)
9 | fun setAPIUrl(url: String)
10 | fun streamChatMessage(messageRequest: MessageRequest): Flow
11 | }
12 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/data/network/AnthropicAPIImpl.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.data.network
2 |
3 | import dev.chungjungsoo.gptmobile.data.ModelConstants
4 | import dev.chungjungsoo.gptmobile.data.dto.anthropic.request.MessageRequest
5 | import dev.chungjungsoo.gptmobile.data.dto.anthropic.response.ErrorDetail
6 | import dev.chungjungsoo.gptmobile.data.dto.anthropic.response.ErrorResponseChunk
7 | import dev.chungjungsoo.gptmobile.data.dto.anthropic.response.MessageResponseChunk
8 | import io.ktor.client.call.body
9 | import io.ktor.client.request.HttpRequestBuilder
10 | import io.ktor.client.request.accept
11 | import io.ktor.client.request.headers
12 | import io.ktor.client.request.setBody
13 | import io.ktor.client.request.url
14 | import io.ktor.client.statement.HttpResponse
15 | import io.ktor.client.statement.HttpStatement
16 | import io.ktor.http.ContentType
17 | import io.ktor.http.HttpMethod
18 | import io.ktor.http.contentType
19 | import io.ktor.utils.io.ByteReadChannel
20 | import io.ktor.utils.io.cancel
21 | import io.ktor.utils.io.readUTF8Line
22 | import javax.inject.Inject
23 | import kotlinx.coroutines.currentCoroutineContext
24 | import kotlinx.coroutines.flow.Flow
25 | import kotlinx.coroutines.flow.FlowCollector
26 | import kotlinx.coroutines.flow.flow
27 | import kotlinx.coroutines.isActive
28 | import kotlinx.serialization.json.Json
29 | import kotlinx.serialization.json.encodeToJsonElement
30 |
31 | class AnthropicAPIImpl @Inject constructor(
32 | private val networkClient: NetworkClient
33 | ) : AnthropicAPI {
34 |
35 | private var token: String? = null
36 | private var apiUrl: String = ModelConstants.ANTHROPIC_API_URL
37 |
38 | override fun setToken(token: String?) {
39 | this.token = token
40 | }
41 |
42 | override fun setAPIUrl(url: String) {
43 | this.apiUrl = url
44 | }
45 |
46 | override fun streamChatMessage(messageRequest: MessageRequest): Flow {
47 | val body = Json.encodeToJsonElement(messageRequest)
48 |
49 | val builder = HttpRequestBuilder().apply {
50 | method = HttpMethod.Post
51 | if (apiUrl.endsWith("/")) url("${apiUrl}v1/messages") else url("$apiUrl/v1/messages")
52 | contentType(ContentType.Application.Json)
53 | setBody(body)
54 | accept(ContentType.Text.EventStream)
55 | headers {
56 | append(API_KEY_HEADER, token ?: "")
57 | append(VERSION_HEADER, ANTHROPIC_VERSION)
58 | }
59 | }
60 |
61 | return flow {
62 | try {
63 | HttpStatement(builder = builder, client = networkClient()).execute {
64 | streamEventsFrom(it)
65 | }
66 | } catch (e: Exception) {
67 | emit(ErrorResponseChunk(error = ErrorDetail(type = "network_error", message = e.message ?: "")))
68 | }
69 | }
70 | }
71 |
72 | private suspend inline fun FlowCollector.streamEventsFrom(response: HttpResponse) {
73 | val channel: ByteReadChannel = response.body()
74 | try {
75 | while (currentCoroutineContext().isActive && !channel.isClosedForRead) {
76 | val line = channel.readUTF8Line() ?: continue
77 | val value: T = when {
78 | line.startsWith(STREAM_END_TOKEN) -> break
79 | line.startsWith(STREAM_PREFIX) -> Json.decodeFromString(line.removePrefix(STREAM_PREFIX))
80 | else -> continue
81 | }
82 | emit(value)
83 | }
84 | } finally {
85 | channel.cancel()
86 | }
87 | }
88 |
89 | companion object {
90 | private const val STREAM_PREFIX = "data:"
91 | private const val STREAM_END_TOKEN = "event: message_stop"
92 | private const val API_KEY_HEADER = "x-api-key"
93 | private const val VERSION_HEADER = "anthropic-version"
94 | private const val ANTHROPIC_VERSION = "2023-06-01"
95 | }
96 | }
97 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/data/network/NetworkClient.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.data.network
2 |
3 | import io.ktor.client.HttpClient
4 | import io.ktor.client.engine.HttpClientEngineFactory
5 | import io.ktor.client.plugins.DefaultRequest
6 | import io.ktor.client.plugins.HttpTimeout
7 | import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
8 | import io.ktor.client.plugins.logging.DEFAULT
9 | import io.ktor.client.plugins.logging.LogLevel
10 | import io.ktor.client.plugins.logging.Logger
11 | import io.ktor.client.plugins.logging.Logging
12 | import io.ktor.client.request.header
13 | import io.ktor.http.ContentType
14 | import io.ktor.http.HttpHeaders
15 | import io.ktor.serialization.kotlinx.json.json
16 | import javax.inject.Inject
17 | import javax.inject.Singleton
18 | import kotlinx.serialization.json.Json
19 |
20 | @Singleton
21 | class NetworkClient @Inject constructor(
22 | private val httpEngine: HttpClientEngineFactory<*>
23 | ) {
24 |
25 | private val client by lazy {
26 | HttpClient(httpEngine) {
27 | expectSuccess = true
28 |
29 | install(ContentNegotiation) {
30 | json(
31 | Json {
32 | isLenient = true
33 | ignoreUnknownKeys = true
34 | allowSpecialFloatingPointValues = true
35 | useArrayPolymorphism = true
36 | }
37 | )
38 | }
39 |
40 | install(HttpTimeout) {
41 | requestTimeoutMillis = TIMEOUT.toLong()
42 | }
43 |
44 | install(Logging) {
45 | logger = Logger.DEFAULT
46 | level = LogLevel.ALL
47 | sanitizeHeader { header -> header == HttpHeaders.Authorization }
48 | }
49 |
50 | install(DefaultRequest) {
51 | header(HttpHeaders.ContentType, ContentType.Application.Json)
52 | }
53 | }
54 | }
55 |
56 | operator fun invoke(): HttpClient = client
57 |
58 | companion object {
59 | private const val TIMEOUT = 1_000 * 60 * 5
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/data/repository/ChatRepository.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.data.repository
2 |
3 | import dev.chungjungsoo.gptmobile.data.database.entity.ChatRoom
4 | import dev.chungjungsoo.gptmobile.data.database.entity.Message
5 | import dev.chungjungsoo.gptmobile.data.dto.ApiState
6 | import kotlinx.coroutines.flow.Flow
7 |
8 | interface ChatRepository {
9 |
10 | suspend fun completeOpenAIChat(question: Message, history: List): Flow
11 | suspend fun completeAnthropicChat(question: Message, history: List): Flow
12 | suspend fun completeGoogleChat(question: Message, history: List): Flow
13 | suspend fun completeOllamaChat(question: Message, history: List): Flow
14 | suspend fun fetchChatList(): List
15 | suspend fun fetchMessages(chatId: Int): List
16 | suspend fun updateChatTitle(chatRoom: ChatRoom, title: String)
17 | suspend fun saveChat(chatRoom: ChatRoom, messages: List): ChatRoom
18 | suspend fun deleteChats(chatRooms: List)
19 | }
20 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/data/repository/SettingRepository.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.data.repository
2 |
3 | import dev.chungjungsoo.gptmobile.data.dto.Platform
4 | import dev.chungjungsoo.gptmobile.data.dto.ThemeSetting
5 |
6 | interface SettingRepository {
7 | suspend fun fetchPlatforms(): List
8 | suspend fun fetchThemes(): ThemeSetting
9 | suspend fun updatePlatforms(platforms: List)
10 | suspend fun updateThemes(themeSetting: ThemeSetting)
11 | }
12 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/data/repository/SettingRepositoryImpl.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.data.repository
2 |
3 | import dev.chungjungsoo.gptmobile.data.ModelConstants
4 | import dev.chungjungsoo.gptmobile.data.datastore.SettingDataSource
5 | import dev.chungjungsoo.gptmobile.data.dto.Platform
6 | import dev.chungjungsoo.gptmobile.data.dto.ThemeSetting
7 | import dev.chungjungsoo.gptmobile.data.model.ApiType
8 | import dev.chungjungsoo.gptmobile.data.model.DynamicTheme
9 | import dev.chungjungsoo.gptmobile.data.model.ThemeMode
10 | import javax.inject.Inject
11 |
12 | class SettingRepositoryImpl @Inject constructor(
13 | private val settingDataSource: SettingDataSource
14 | ) : SettingRepository {
15 |
16 | override suspend fun fetchPlatforms(): List = ApiType.entries.map { apiType ->
17 | val status = settingDataSource.getStatus(apiType)
18 | val apiUrl = when (apiType) {
19 | ApiType.OPENAI -> settingDataSource.getAPIUrl(apiType) ?: ModelConstants.OPENAI_API_URL
20 | ApiType.ANTHROPIC -> settingDataSource.getAPIUrl(apiType) ?: ModelConstants.ANTHROPIC_API_URL
21 | ApiType.GOOGLE -> settingDataSource.getAPIUrl(apiType) ?: ModelConstants.GOOGLE_API_URL
22 | ApiType.OLLAMA -> settingDataSource.getAPIUrl(apiType) ?: ""
23 | }
24 | val token = settingDataSource.getToken(apiType)
25 | val model = settingDataSource.getModel(apiType)
26 | val temperature = settingDataSource.getTemperature(apiType)
27 | val topP = settingDataSource.getTopP(apiType)
28 | val systemPrompt = when (apiType) {
29 | ApiType.OPENAI -> settingDataSource.getSystemPrompt(ApiType.OPENAI) ?: ModelConstants.OPENAI_PROMPT
30 | ApiType.ANTHROPIC -> settingDataSource.getSystemPrompt(ApiType.ANTHROPIC) ?: ModelConstants.DEFAULT_PROMPT
31 | ApiType.GOOGLE -> settingDataSource.getSystemPrompt(ApiType.GOOGLE) ?: ModelConstants.DEFAULT_PROMPT
32 | ApiType.OLLAMA -> settingDataSource.getSystemPrompt(ApiType.OLLAMA) ?: ModelConstants.DEFAULT_PROMPT
33 | }
34 |
35 | Platform(
36 | name = apiType,
37 | enabled = status == true,
38 | apiUrl = apiUrl,
39 | token = token,
40 | model = model,
41 | temperature = temperature,
42 | topP = topP,
43 | systemPrompt = systemPrompt
44 | )
45 | }
46 |
47 | override suspend fun fetchThemes(): ThemeSetting = ThemeSetting(
48 | dynamicTheme = settingDataSource.getDynamicTheme() ?: DynamicTheme.OFF,
49 | themeMode = settingDataSource.getThemeMode() ?: ThemeMode.SYSTEM
50 | )
51 |
52 | override suspend fun updatePlatforms(platforms: List) {
53 | platforms.forEach { platform ->
54 | settingDataSource.updateStatus(platform.name, platform.enabled)
55 | settingDataSource.updateAPIUrl(platform.name, platform.apiUrl)
56 |
57 | platform.token?.let { settingDataSource.updateToken(platform.name, it) }
58 | platform.model?.let { settingDataSource.updateModel(platform.name, it) }
59 | platform.temperature?.let { settingDataSource.updateTemperature(platform.name, it) }
60 | platform.topP?.let { settingDataSource.updateTopP(platform.name, it) }
61 | platform.systemPrompt?.let { settingDataSource.updateSystemPrompt(platform.name, it.trim()) }
62 | }
63 | }
64 |
65 | override suspend fun updateThemes(themeSetting: ThemeSetting) {
66 | settingDataSource.updateDynamicTheme(themeSetting.dynamicTheme)
67 | settingDataSource.updateThemeMode(themeSetting.themeMode)
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/di/ChatRepositoryModule.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.di
2 |
3 | import dagger.Module
4 | import dagger.Provides
5 | import dagger.hilt.InstallIn
6 | import dagger.hilt.components.SingletonComponent
7 | import dev.chungjungsoo.gptmobile.data.database.dao.ChatRoomDao
8 | import dev.chungjungsoo.gptmobile.data.database.dao.MessageDao
9 | import dev.chungjungsoo.gptmobile.data.network.AnthropicAPI
10 | import dev.chungjungsoo.gptmobile.data.repository.ChatRepository
11 | import dev.chungjungsoo.gptmobile.data.repository.ChatRepositoryImpl
12 | import dev.chungjungsoo.gptmobile.data.repository.SettingRepository
13 | import javax.inject.Singleton
14 |
15 | @Module
16 | @InstallIn(SingletonComponent::class)
17 | object ChatRepositoryModule {
18 |
19 | @Provides
20 | @Singleton
21 | fun provideChatRepository(
22 | chatRoomDao: ChatRoomDao,
23 | messageDao: MessageDao,
24 | settingRepository: SettingRepository,
25 | anthropicAPI: AnthropicAPI
26 | ): ChatRepository = ChatRepositoryImpl(chatRoomDao, messageDao, settingRepository, anthropicAPI)
27 | }
28 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/di/DataStoreModule.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.di
2 |
3 | import android.content.Context
4 | import androidx.datastore.core.DataStore
5 | import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
6 | import androidx.datastore.preferences.core.PreferenceDataStoreFactory
7 | import androidx.datastore.preferences.core.Preferences
8 | import androidx.datastore.preferences.core.emptyPreferences
9 | import androidx.datastore.preferences.preferencesDataStoreFile
10 | import dagger.Module
11 | import dagger.Provides
12 | import dagger.hilt.InstallIn
13 | import dagger.hilt.android.qualifiers.ApplicationContext
14 | import dagger.hilt.components.SingletonComponent
15 | import javax.inject.Singleton
16 |
17 | private const val TOKEN_PREF_FILE = "token"
18 |
19 | @Module
20 | @InstallIn(SingletonComponent::class)
21 | object DataStoreModule {
22 | @Provides
23 | @Singleton
24 | fun providePreferencesDataStore(@ApplicationContext applicationContext: Context): DataStore = PreferenceDataStoreFactory.create(
25 | corruptionHandler = ReplaceFileCorruptionHandler(
26 | produceNewData = { emptyPreferences() }
27 | ),
28 | produceFile = { applicationContext.preferencesDataStoreFile(TOKEN_PREF_FILE) }
29 | )
30 | }
31 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/di/DatabaseModule.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.di
2 |
3 | import android.content.Context
4 | import androidx.room.Room
5 | import dagger.Module
6 | import dagger.Provides
7 | import dagger.hilt.InstallIn
8 | import dagger.hilt.android.qualifiers.ApplicationContext
9 | import dagger.hilt.components.SingletonComponent
10 | import dev.chungjungsoo.gptmobile.data.database.ChatDatabase
11 | import dev.chungjungsoo.gptmobile.data.database.dao.ChatRoomDao
12 | import dev.chungjungsoo.gptmobile.data.database.dao.MessageDao
13 | import javax.inject.Singleton
14 |
15 | @Module
16 | @InstallIn(SingletonComponent::class)
17 | object DatabaseModule {
18 | private const val DB_NAME = "chat"
19 |
20 | @Provides
21 | fun provideChatRoomDao(chatDatabase: ChatDatabase): ChatRoomDao = chatDatabase.chatRoomDao()
22 |
23 | @Provides
24 | fun provideMessageDao(chatDatabase: ChatDatabase): MessageDao = chatDatabase.messageDao()
25 |
26 | @Provides
27 | @Singleton
28 | fun provideChatDatabase(@ApplicationContext appContext: Context): ChatDatabase = Room.databaseBuilder(
29 | appContext,
30 | ChatDatabase::class.java,
31 | DB_NAME
32 | ).build()
33 | }
34 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/di/NetworkModule.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.di
2 |
3 | import dagger.Module
4 | import dagger.Provides
5 | import dagger.hilt.InstallIn
6 | import dagger.hilt.components.SingletonComponent
7 | import dev.chungjungsoo.gptmobile.data.network.AnthropicAPI
8 | import dev.chungjungsoo.gptmobile.data.network.AnthropicAPIImpl
9 | import dev.chungjungsoo.gptmobile.data.network.NetworkClient
10 | import io.ktor.client.engine.okhttp.OkHttp
11 | import javax.inject.Singleton
12 |
13 | @Module
14 | @InstallIn(SingletonComponent::class)
15 | object NetworkModule {
16 |
17 | @Provides
18 | @Singleton
19 | fun provideNetworkClient(): NetworkClient = NetworkClient(OkHttp)
20 |
21 | @Provides
22 | @Singleton
23 | fun provideAnthropicAPI(): AnthropicAPI = AnthropicAPIImpl(provideNetworkClient())
24 | }
25 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/di/SettingDataSourceModule.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.di
2 |
3 | import androidx.datastore.core.DataStore
4 | import androidx.datastore.preferences.core.Preferences
5 | import dagger.Module
6 | import dagger.Provides
7 | import dagger.hilt.InstallIn
8 | import dagger.hilt.components.SingletonComponent
9 | import dev.chungjungsoo.gptmobile.data.datastore.SettingDataSource
10 | import dev.chungjungsoo.gptmobile.data.datastore.SettingDataSourceImpl
11 | import javax.inject.Singleton
12 |
13 | @Module
14 | @InstallIn(SingletonComponent::class)
15 | object SettingDataSourceModule {
16 | @Provides
17 | @Singleton
18 | fun provideSettingDataStore(dataStore: DataStore): SettingDataSource = SettingDataSourceImpl(dataStore)
19 | }
20 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/di/SettingRepositoryModule.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.di
2 |
3 | import dagger.Module
4 | import dagger.Provides
5 | import dagger.hilt.InstallIn
6 | import dagger.hilt.components.SingletonComponent
7 | import dev.chungjungsoo.gptmobile.data.datastore.SettingDataSource
8 | import dev.chungjungsoo.gptmobile.data.repository.SettingRepository
9 | import dev.chungjungsoo.gptmobile.data.repository.SettingRepositoryImpl
10 | import javax.inject.Singleton
11 |
12 | @Module
13 | @InstallIn(SingletonComponent::class)
14 | object SettingRepositoryModule {
15 |
16 | @Provides
17 | @Singleton
18 | fun provideSettingRepository(
19 | settingDataSource: SettingDataSource
20 | ): SettingRepository = SettingRepositoryImpl(settingDataSource)
21 | }
22 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/presentation/GPTMobileApp.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.presentation
2 |
3 | import android.app.Application
4 | import android.content.Context
5 | import dagger.hilt.android.HiltAndroidApp
6 | import dagger.hilt.android.qualifiers.ApplicationContext
7 | import javax.inject.Inject
8 |
9 | @HiltAndroidApp
10 | class GPTMobileApp : Application() {
11 | // TODO Delete when https://github.com/google/dagger/issues/3601 is resolved.
12 | @Inject
13 | @ApplicationContext
14 | lateinit var context: Context
15 | }
16 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/presentation/common/PlatformCheckBoxItem.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.presentation.common
2 |
3 | import androidx.compose.foundation.LocalIndication
4 | import androidx.compose.foundation.clickable
5 | import androidx.compose.foundation.interaction.MutableInteractionSource
6 | import androidx.compose.foundation.layout.Column
7 | import androidx.compose.foundation.layout.Row
8 | import androidx.compose.foundation.layout.fillMaxWidth
9 | import androidx.compose.foundation.layout.padding
10 | import androidx.compose.material3.Checkbox
11 | import androidx.compose.material3.MaterialTheme
12 | import androidx.compose.material3.Text
13 | import androidx.compose.runtime.Composable
14 | import androidx.compose.runtime.remember
15 | import androidx.compose.ui.Alignment
16 | import androidx.compose.ui.Modifier
17 | import androidx.compose.ui.draw.alpha
18 | import androidx.compose.ui.res.stringResource
19 | import androidx.compose.ui.unit.dp
20 | import dev.chungjungsoo.gptmobile.R
21 | import dev.chungjungsoo.gptmobile.data.dto.Platform
22 |
23 | @Composable
24 | fun PlatformCheckBoxItem(
25 | modifier: Modifier = Modifier,
26 | platform: Platform,
27 | enabled: Boolean = true,
28 | title: String = stringResource(R.string.sample_item_title),
29 | description: String? = stringResource(R.string.sample_item_description),
30 | onClickEvent: (Platform) -> Unit
31 | ) {
32 | val interactionSource = remember { MutableInteractionSource() }
33 | val rowModifier = if (enabled) {
34 | modifier
35 | .fillMaxWidth()
36 | .clickable(
37 | interactionSource = interactionSource,
38 | indication = LocalIndication.current
39 | ) { onClickEvent.invoke(platform) }
40 | .padding(top = 12.dp, bottom = 12.dp, start = 16.dp, end = 16.dp)
41 | } else {
42 | modifier
43 | .fillMaxWidth()
44 | .padding(top = 12.dp, bottom = 12.dp, start = 16.dp, end = 16.dp)
45 | }
46 | val textModifier = Modifier.alpha(if (enabled) 1.0f else 0.38f)
47 |
48 | Row(
49 | modifier = rowModifier,
50 | verticalAlignment = Alignment.CenterVertically
51 | ) {
52 | Checkbox(
53 | enabled = enabled,
54 | checked = platform.selected,
55 | interactionSource = interactionSource,
56 | onCheckedChange = { onClickEvent.invoke(platform) }
57 | )
58 | Column(horizontalAlignment = Alignment.Start) {
59 | Text(
60 | text = title,
61 | modifier = textModifier,
62 | style = MaterialTheme.typography.titleMedium
63 | )
64 | description?.let {
65 | Text(
66 | text = it,
67 | modifier = textModifier,
68 | style = MaterialTheme.typography.bodySmall
69 | )
70 | }
71 | }
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/presentation/common/PrimaryLongButton.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.presentation.common
2 |
3 | import androidx.compose.foundation.layout.fillMaxWidth
4 | import androidx.compose.foundation.layout.height
5 | import androidx.compose.foundation.layout.padding
6 | import androidx.compose.material3.Button
7 | import androidx.compose.material3.Text
8 | import androidx.compose.runtime.Composable
9 | import androidx.compose.ui.Modifier
10 | import androidx.compose.ui.tooling.preview.Preview
11 | import androidx.compose.ui.unit.dp
12 |
13 | @Preview
14 | @Composable
15 | fun PrimaryLongButton(
16 | modifier: Modifier = Modifier,
17 | enabled: Boolean = true,
18 | onClick: () -> Unit = {},
19 | text: String = ""
20 | ) {
21 | Button(
22 | modifier = modifier
23 | .padding(20.dp)
24 | .fillMaxWidth()
25 | .height(56.dp),
26 | onClick = onClick,
27 | enabled = enabled
28 | ) {
29 | Text(text = text)
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/presentation/common/RadioItem.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.presentation.common
2 |
3 | import androidx.compose.foundation.LocalIndication
4 | import androidx.compose.foundation.interaction.MutableInteractionSource
5 | import androidx.compose.foundation.layout.Column
6 | import androidx.compose.foundation.layout.Row
7 | import androidx.compose.foundation.layout.fillMaxWidth
8 | import androidx.compose.foundation.layout.padding
9 | import androidx.compose.foundation.selection.selectable
10 | import androidx.compose.material3.MaterialTheme
11 | import androidx.compose.material3.RadioButton
12 | import androidx.compose.material3.Text
13 | import androidx.compose.runtime.Composable
14 | import androidx.compose.runtime.remember
15 | import androidx.compose.ui.Alignment
16 | import androidx.compose.ui.Modifier
17 | import androidx.compose.ui.res.stringResource
18 | import androidx.compose.ui.semantics.Role
19 | import androidx.compose.ui.tooling.preview.Preview
20 | import androidx.compose.ui.unit.dp
21 | import dev.chungjungsoo.gptmobile.R
22 |
23 | @Preview
24 | @Composable
25 | fun RadioItem(
26 | modifier: Modifier = Modifier,
27 | value: String = stringResource(R.string.sample_item_title),
28 | selected: Boolean = false,
29 | title: String = stringResource(R.string.sample_item_title),
30 | description: String? = stringResource(R.string.sample_item_description),
31 | onSelected: (String) -> Unit = { }
32 | ) {
33 | val interactionSource = remember { MutableInteractionSource() }
34 | Row(
35 | modifier = modifier
36 | .fillMaxWidth()
37 | .selectable(
38 | selected = selected,
39 | onClick = { onSelected(value) },
40 | interactionSource = interactionSource,
41 | indication = LocalIndication.current,
42 | role = Role.RadioButton
43 | )
44 | .padding(20.dp),
45 | verticalAlignment = Alignment.CenterVertically
46 | ) {
47 | RadioButton(
48 | selected = selected,
49 | onClick = null,
50 | interactionSource = interactionSource
51 | )
52 | Column(
53 | modifier = Modifier.padding(start = 16.dp),
54 | horizontalAlignment = Alignment.Start
55 | ) {
56 | Text(
57 | text = title,
58 | style = MaterialTheme.typography.titleMedium
59 | )
60 | description?.let {
61 | Text(
62 | text = it,
63 | style = MaterialTheme.typography.bodySmall
64 | )
65 | }
66 | }
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/presentation/common/Route.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.presentation.common
2 |
3 | object Route {
4 |
5 | const val GET_STARTED = "get_started"
6 |
7 | const val SETUP_ROUTE = "setup_route"
8 | const val SELECT_PLATFORM = "select_platform"
9 | const val TOKEN_INPUT = "token_input"
10 | const val OPENAI_MODEL_SELECT = "openai_model_select"
11 | const val ANTHROPIC_MODEL_SELECT = "anthropic_model_select"
12 | const val GOOGLE_MODEL_SELECT = "google_model_select"
13 | const val OLLAMA_MODEL_SELECT = "ollama_model_select"
14 | const val OLLAMA_API_ADDRESS = "ollama_api_address"
15 | const val SETUP_COMPLETE = "setup_complete"
16 |
17 | const val CHAT_LIST = "chat_list"
18 | const val CHAT_ROOM = "chat_room/{chatRoomId}?enabled={enabledPlatforms}"
19 |
20 | const val SETTING_ROUTE = "setting_route"
21 | const val SETTINGS = "settings"
22 | const val OPENAI_SETTINGS = "openai_settings"
23 | const val ANTHROPIC_SETTINGS = "anthropic_settings"
24 | const val GOOGLE_SETTINGS = "google_settings"
25 | const val OLLAMA_SETTINGS = "ollama_settings"
26 | const val ABOUT_PAGE = "about"
27 | const val LICENSE = "license"
28 | }
29 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/presentation/common/SettingItem.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.presentation.common
2 |
3 | import androidx.compose.foundation.clickable
4 | import androidx.compose.foundation.layout.fillMaxWidth
5 | import androidx.compose.foundation.layout.padding
6 | import androidx.compose.material3.Icon
7 | import androidx.compose.material3.ListItem
8 | import androidx.compose.material3.ListItemDefaults
9 | import androidx.compose.material3.Text
10 | import androidx.compose.runtime.Composable
11 | import androidx.compose.ui.Modifier
12 | import androidx.compose.ui.graphics.vector.ImageVector
13 | import androidx.compose.ui.res.stringResource
14 | import androidx.compose.ui.res.vectorResource
15 | import androidx.compose.ui.text.style.TextOverflow
16 | import androidx.compose.ui.unit.dp
17 | import dev.chungjungsoo.gptmobile.R
18 |
19 | @Composable
20 | fun SettingItem(
21 | modifier: Modifier = Modifier,
22 | title: String,
23 | description: String? = null,
24 | enabled: Boolean = true,
25 | onItemClick: () -> Unit,
26 | showTrailingIcon: Boolean,
27 | showLeadingIcon: Boolean,
28 | leadingIcon: @Composable () -> Unit? = {}
29 | ) {
30 | val clickableModifier = if (enabled) {
31 | modifier
32 | .fillMaxWidth()
33 | .clickable(onClick = onItemClick)
34 | .padding(horizontal = 8.dp)
35 | } else {
36 | modifier
37 | .fillMaxWidth()
38 | .padding(horizontal = 8.dp)
39 | }
40 | val colors = ListItemDefaults.colors()
41 |
42 | if (showLeadingIcon) {
43 | ListItem(
44 | modifier = clickableModifier,
45 | headlineContent = { Text(title, overflow = TextOverflow.Ellipsis) },
46 | supportingContent = {
47 | description?.let { Text(it, overflow = TextOverflow.Ellipsis) }
48 | },
49 | leadingContent = { leadingIcon() },
50 | trailingContent = {
51 | if (showTrailingIcon) {
52 | Icon(
53 | ImageVector.vectorResource(id = R.drawable.ic_round_arrow_right),
54 | contentDescription = stringResource(R.string.arrow_icon)
55 | )
56 | }
57 | },
58 | colors = ListItemDefaults.colors(
59 | headlineColor = if (enabled) colors.headlineColor else colors.disabledHeadlineColor,
60 | supportingColor = if (enabled) colors.supportingTextColor else colors.disabledHeadlineColor,
61 | trailingIconColor = if (enabled) colors.trailingIconColor else colors.disabledTrailingIconColor
62 | )
63 | )
64 | } else {
65 | ListItem(
66 | modifier = clickableModifier,
67 | headlineContent = { Text(title) },
68 | supportingContent = {
69 | description?.let { Text(it) }
70 | },
71 | trailingContent = {
72 | if (showTrailingIcon) {
73 | Icon(
74 | ImageVector.vectorResource(id = R.drawable.ic_round_arrow_right),
75 | contentDescription = stringResource(R.string.arrow_icon)
76 | )
77 | }
78 | },
79 | colors = ListItemDefaults.colors(
80 | headlineColor = if (enabled) colors.headlineColor else colors.disabledHeadlineColor,
81 | supportingColor = if (enabled) colors.supportingTextColor else colors.disabledHeadlineColor,
82 | trailingIconColor = if (enabled) colors.trailingIconColor else colors.disabledTrailingIconColor
83 | )
84 | )
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/presentation/common/ThemeSettingProvider.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.presentation.common
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.runtime.CompositionLocalProvider
5 | import androidx.compose.runtime.compositionLocalOf
6 | import androidx.hilt.navigation.compose.hiltViewModel
7 | import dev.chungjungsoo.gptmobile.data.model.DynamicTheme
8 | import dev.chungjungsoo.gptmobile.data.model.ThemeMode
9 | import dev.chungjungsoo.gptmobile.util.collectManagedState
10 |
11 | val LocalDynamicTheme = compositionLocalOf { DynamicTheme.OFF }
12 | val LocalThemeMode = compositionLocalOf { ThemeMode.SYSTEM }
13 | val LocalThemeViewModel = compositionLocalOf {
14 | error("CompositionLocal LocalThemeViewModel is not present")
15 | }
16 |
17 | @Composable
18 | fun ThemeSettingProvider(
19 | themeViewModel: ThemeViewModel = hiltViewModel(),
20 | content: @Composable () -> Unit
21 | ) {
22 | themeViewModel.themeSetting.collectManagedState().value.run {
23 | CompositionLocalProvider(
24 | LocalThemeViewModel provides themeViewModel,
25 | LocalDynamicTheme provides dynamicTheme,
26 | LocalThemeMode provides themeMode,
27 | content = content
28 | )
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/presentation/common/ThemeViewModel.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.presentation.common
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.viewModelScope
5 | import dagger.hilt.android.lifecycle.HiltViewModel
6 | import dev.chungjungsoo.gptmobile.data.dto.ThemeSetting
7 | import dev.chungjungsoo.gptmobile.data.model.DynamicTheme
8 | import dev.chungjungsoo.gptmobile.data.model.ThemeMode
9 | import dev.chungjungsoo.gptmobile.data.repository.SettingRepository
10 | import javax.inject.Inject
11 | import kotlinx.coroutines.flow.MutableStateFlow
12 | import kotlinx.coroutines.flow.asStateFlow
13 | import kotlinx.coroutines.flow.update
14 | import kotlinx.coroutines.launch
15 |
16 | @HiltViewModel
17 | class ThemeViewModel @Inject constructor(private val settingRepository: SettingRepository) : ViewModel() {
18 |
19 | private val _themeSetting = MutableStateFlow(ThemeSetting())
20 | val themeSetting = _themeSetting.asStateFlow()
21 |
22 | init {
23 | fetchThemes()
24 | }
25 |
26 | private fun fetchThemes() {
27 | viewModelScope.launch {
28 | _themeSetting.update { settingRepository.fetchThemes() }
29 | }
30 | }
31 |
32 | fun updateDynamicTheme(theme: DynamicTheme) {
33 | _themeSetting.update { setting ->
34 | setting.copy(dynamicTheme = theme)
35 | }
36 | viewModelScope.launch {
37 | settingRepository.updateThemes(_themeSetting.value)
38 | }
39 | }
40 |
41 | fun updateThemeMode(theme: ThemeMode) {
42 | _themeSetting.update { setting ->
43 | setting.copy(themeMode = theme)
44 | }
45 | viewModelScope.launch {
46 | settingRepository.updateThemes(_themeSetting.value)
47 | }
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/presentation/common/TokenInputField.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.presentation.common
2 |
3 | import androidx.compose.foundation.layout.fillMaxWidth
4 | import androidx.compose.foundation.layout.padding
5 | import androidx.compose.foundation.text.KeyboardOptions
6 | import androidx.compose.material.icons.Icons
7 | import androidx.compose.material.icons.outlined.Clear
8 | import androidx.compose.material3.Icon
9 | import androidx.compose.material3.IconButton
10 | import androidx.compose.material3.OutlinedTextField
11 | import androidx.compose.material3.Text
12 | import androidx.compose.runtime.Composable
13 | import androidx.compose.ui.Modifier
14 | import androidx.compose.ui.res.stringResource
15 | import androidx.compose.ui.text.LinkAnnotation
16 | import androidx.compose.ui.text.buildAnnotatedString
17 | import androidx.compose.ui.text.input.PasswordVisualTransformation
18 | import androidx.compose.ui.text.withLink
19 | import androidx.compose.ui.tooling.preview.Preview
20 | import androidx.compose.ui.unit.dp
21 | import dev.chungjungsoo.gptmobile.R
22 |
23 | @Preview
24 | @Composable
25 | fun TokenInputField(
26 | modifier: Modifier = Modifier,
27 | value: String = "",
28 | onValueChange: (String) -> Unit = { },
29 | keyboardOptions: KeyboardOptions = KeyboardOptions(),
30 | onClearClick: () -> Unit = { },
31 | label: String = "",
32 | helpLink: String = ""
33 | ) {
34 | OutlinedTextField(
35 | modifier = modifier
36 | .fillMaxWidth()
37 | .padding(top = 16.dp, bottom = 16.dp, start = 20.dp, end = 20.dp),
38 | value = value,
39 | onValueChange = onValueChange,
40 | label = {
41 | Text(label)
42 | },
43 | singleLine = true,
44 | visualTransformation = PasswordVisualTransformation(),
45 | keyboardOptions = keyboardOptions,
46 | supportingText = {
47 | HelpText(helpLink)
48 | },
49 | trailingIcon = {
50 | if (value.isNotBlank()) {
51 | IconButton(onClick = onClearClick) {
52 | Icon(Icons.Outlined.Clear, contentDescription = stringResource(R.string.clear_token))
53 | }
54 | }
55 | }
56 | )
57 | }
58 |
59 | @Preview
60 | @Composable
61 | fun HelpText(helpLink: String = "") {
62 | val annotatedString = buildAnnotatedString {
63 | val str = stringResource(R.string.need_help)
64 | withLink(LinkAnnotation.Url(url = helpLink)) {
65 | append(str)
66 | }
67 | }
68 | Text(annotatedString)
69 | }
70 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/presentation/icons/Done.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.presentation.icons
2 |
3 | import androidx.compose.material3.MaterialTheme
4 | import androidx.compose.runtime.Composable
5 | import androidx.compose.ui.graphics.PathFillType.Companion.NonZero
6 | import androidx.compose.ui.graphics.SolidColor
7 | import androidx.compose.ui.graphics.StrokeCap.Companion.Butt
8 | import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter
9 | import androidx.compose.ui.graphics.vector.ImageVector
10 | import androidx.compose.ui.graphics.vector.ImageVector.Builder
11 | import androidx.compose.ui.graphics.vector.path
12 | import androidx.compose.ui.unit.dp
13 |
14 | val Done: ImageVector
15 | // It should be recomposed when theme is changed. So calculate every time (Expensive, but only used in setup complete screen)
16 | @Composable
17 | get() {
18 | return Builder(
19 | name = "IcDone",
20 | defaultWidth = 48.0.dp,
21 | defaultHeight = 48.0.dp,
22 | viewportWidth = 960.0f,
23 | viewportHeight = 960.0f
24 | ).apply {
25 | path(
26 | fill = SolidColor(MaterialTheme.colorScheme.primary),
27 | stroke = null,
28 | strokeLineWidth = 0.0f,
29 | strokeLineCap = Butt,
30 | strokeLineJoin = Miter,
31 | strokeLineMiter = 4.0f,
32 | pathFillType = NonZero
33 | ) {
34 | moveToRelative(421.0f, 571.0f)
35 | lineToRelative(-98.0f, -98.0f)
36 | quadToRelative(-9.0f, -9.0f, -22.0f, -9.0f)
37 | reflectiveQuadToRelative(-23.0f, 10.0f)
38 | quadToRelative(-9.0f, 9.0f, -9.0f, 22.0f)
39 | reflectiveQuadToRelative(9.0f, 22.0f)
40 | lineToRelative(122.0f, 123.0f)
41 | quadToRelative(9.0f, 9.0f, 21.0f, 9.0f)
42 | reflectiveQuadToRelative(21.0f, -9.0f)
43 | lineToRelative(239.0f, -239.0f)
44 | quadToRelative(10.0f, -10.0f, 10.0f, -23.0f)
45 | reflectiveQuadToRelative(-10.0f, -23.0f)
46 | quadToRelative(-10.0f, -9.0f, -23.5f, -8.5f)
47 | reflectiveQuadTo(635.0f, 357.0f)
48 | lineTo(421.0f, 571.0f)
49 | close()
50 | moveTo(480.0f, 880.0f)
51 | quadToRelative(-82.0f, 0.0f, -155.0f, -31.5f)
52 | reflectiveQuadToRelative(-127.5f, -86.0f)
53 | quadTo(143.0f, 708.0f, 111.5f, 635.0f)
54 | reflectiveQuadTo(80.0f, 480.0f)
55 | quadToRelative(0.0f, -83.0f, 31.5f, -156.0f)
56 | reflectiveQuadToRelative(86.0f, -127.0f)
57 | quadTo(252.0f, 143.0f, 325.0f, 111.5f)
58 | reflectiveQuadTo(480.0f, 80.0f)
59 | quadToRelative(83.0f, 0.0f, 156.0f, 31.5f)
60 | reflectiveQuadTo(763.0f, 197.0f)
61 | quadToRelative(54.0f, 54.0f, 85.5f, 127.0f)
62 | reflectiveQuadTo(880.0f, 480.0f)
63 | quadToRelative(0.0f, 82.0f, -31.5f, 155.0f)
64 | reflectiveQuadTo(763.0f, 762.5f)
65 | quadToRelative(-54.0f, 54.5f, -127.0f, 86.0f)
66 | reflectiveQuadTo(480.0f, 880.0f)
67 | close()
68 | moveTo(480.0f, 820.0f)
69 | quadToRelative(142.0f, 0.0f, 241.0f, -99.5f)
70 | reflectiveQuadTo(820.0f, 480.0f)
71 | quadToRelative(0.0f, -142.0f, -99.0f, -241.0f)
72 | reflectiveQuadToRelative(-241.0f, -99.0f)
73 | quadToRelative(-141.0f, 0.0f, -240.5f, 99.0f)
74 | reflectiveQuadTo(140.0f, 480.0f)
75 | quadToRelative(0.0f, 141.0f, 99.5f, 240.5f)
76 | reflectiveQuadTo(480.0f, 820.0f)
77 | close()
78 | moveTo(480.0f, 480.0f)
79 | close()
80 | }
81 | }
82 | .build()
83 | }
84 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/presentation/theme/Type.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.presentation.theme
2 |
3 | import androidx.compose.material3.Typography
4 |
5 | val AppTypography = Typography()
6 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/presentation/ui/home/HomeViewModel.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.presentation.ui.home
2 |
3 | import android.util.Log
4 | import androidx.lifecycle.ViewModel
5 | import androidx.lifecycle.viewModelScope
6 | import dagger.hilt.android.lifecycle.HiltViewModel
7 | import dev.chungjungsoo.gptmobile.data.database.entity.ChatRoom
8 | import dev.chungjungsoo.gptmobile.data.dto.Platform
9 | import dev.chungjungsoo.gptmobile.data.repository.ChatRepository
10 | import dev.chungjungsoo.gptmobile.data.repository.SettingRepository
11 | import javax.inject.Inject
12 | import kotlinx.coroutines.flow.MutableStateFlow
13 | import kotlinx.coroutines.flow.StateFlow
14 | import kotlinx.coroutines.flow.asStateFlow
15 | import kotlinx.coroutines.flow.update
16 | import kotlinx.coroutines.launch
17 |
18 | @HiltViewModel
19 | class HomeViewModel @Inject constructor(
20 | private val chatRepository: ChatRepository,
21 | private val settingRepository: SettingRepository
22 | ) : ViewModel() {
23 |
24 | data class ChatListState(
25 | val chats: List = listOf(),
26 | val isSelectionMode: Boolean = false,
27 | val selected: List = listOf()
28 | )
29 |
30 | private val _chatListState = MutableStateFlow(ChatListState())
31 | val chatListState: StateFlow = _chatListState.asStateFlow()
32 |
33 | private val _platformState = MutableStateFlow(listOf())
34 | val platformState: StateFlow> = _platformState.asStateFlow()
35 |
36 | private val _showSelectModelDialog = MutableStateFlow(false)
37 | val showSelectModelDialog: StateFlow = _showSelectModelDialog.asStateFlow()
38 |
39 | private val _showDeleteWarningDialog = MutableStateFlow(false)
40 | val showDeleteWarningDialog: StateFlow = _showDeleteWarningDialog.asStateFlow()
41 |
42 | fun updateCheckedState(platform: Platform) {
43 | val index = _platformState.value.indexOf(platform)
44 |
45 | if (index >= 0) {
46 | _platformState.update {
47 | it.mapIndexed { i, p ->
48 | if (index == i) {
49 | p.copy(selected = p.selected.not())
50 | } else {
51 | p
52 | }
53 | }
54 | }
55 | }
56 | }
57 |
58 | fun openDeleteWarningDialog() {
59 | closeSelectModelDialog()
60 | _showDeleteWarningDialog.update { true }
61 | }
62 |
63 | fun closeDeleteWarningDialog() {
64 | _showDeleteWarningDialog.update { false }
65 | }
66 |
67 | fun openSelectModelDialog() {
68 | _showSelectModelDialog.update { true }
69 | disableSelectionMode()
70 | }
71 |
72 | fun closeSelectModelDialog() {
73 | _showSelectModelDialog.update { false }
74 | }
75 |
76 | fun deleteSelectedChats() {
77 | viewModelScope.launch {
78 | val selectedChats = _chatListState.value.chats.filterIndexed { index, _ ->
79 | _chatListState.value.selected[index]
80 | }
81 |
82 | chatRepository.deleteChats(selectedChats)
83 | _chatListState.update { it.copy(chats = chatRepository.fetchChatList()) }
84 | disableSelectionMode()
85 | }
86 | }
87 |
88 | fun disableSelectionMode() {
89 | _chatListState.update {
90 | it.copy(
91 | selected = List(it.chats.size) { false },
92 | isSelectionMode = false
93 | )
94 | }
95 | }
96 |
97 | fun enableSelectionMode() {
98 | _chatListState.update { it.copy(isSelectionMode = true) }
99 | }
100 |
101 | fun fetchChats() {
102 | viewModelScope.launch {
103 | val chats = chatRepository.fetchChatList()
104 |
105 | _chatListState.update {
106 | it.copy(
107 | chats = chats,
108 | selected = List(chats.size) { false },
109 | isSelectionMode = false
110 | )
111 | }
112 |
113 | Log.d("chats", "${_chatListState.value.chats}")
114 | }
115 | }
116 |
117 | fun fetchPlatformStatus() {
118 | viewModelScope.launch {
119 | val platforms = settingRepository.fetchPlatforms()
120 | _platformState.update { platforms }
121 | }
122 | }
123 |
124 | fun selectChat(chatRoomIdx: Int) {
125 | if (chatRoomIdx < 0 || chatRoomIdx > _chatListState.value.chats.size) return
126 |
127 | _chatListState.update {
128 | it.copy(
129 | selected = it.selected.mapIndexed { index, b ->
130 | if (index == chatRoomIdx) {
131 | !b
132 | } else {
133 | b
134 | }
135 | }
136 | )
137 | }
138 |
139 | if (_chatListState.value.selected.count { it } == 0) {
140 | disableSelectionMode()
141 | }
142 | }
143 | }
144 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/presentation/ui/main/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.presentation.ui.main
2 |
3 | import android.os.Bundle
4 | import androidx.activity.ComponentActivity
5 | import androidx.activity.compose.setContent
6 | import androidx.activity.enableEdgeToEdge
7 | import androidx.activity.viewModels
8 | import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
9 | import androidx.lifecycle.Lifecycle
10 | import androidx.lifecycle.lifecycleScope
11 | import androidx.lifecycle.repeatOnLifecycle
12 | import androidx.navigation.NavHostController
13 | import androidx.navigation.compose.rememberNavController
14 | import dagger.hilt.android.AndroidEntryPoint
15 | import dev.chungjungsoo.gptmobile.presentation.common.LocalDynamicTheme
16 | import dev.chungjungsoo.gptmobile.presentation.common.LocalThemeMode
17 | import dev.chungjungsoo.gptmobile.presentation.common.Route
18 | import dev.chungjungsoo.gptmobile.presentation.common.SetupNavGraph
19 | import dev.chungjungsoo.gptmobile.presentation.common.ThemeSettingProvider
20 | import dev.chungjungsoo.gptmobile.presentation.theme.GPTMobileTheme
21 | import kotlinx.coroutines.launch
22 |
23 | @AndroidEntryPoint
24 | class MainActivity : ComponentActivity() {
25 |
26 | private val mainViewModel: MainViewModel by viewModels()
27 |
28 | override fun onCreate(savedInstanceState: Bundle?) {
29 | installSplashScreen().apply {
30 | setKeepOnScreenCondition {
31 | !mainViewModel.isReady.value
32 | }
33 | }
34 | enableEdgeToEdge()
35 | super.onCreate(savedInstanceState)
36 |
37 | setContent {
38 | val navController = rememberNavController()
39 | navController.checkForExistingSettings()
40 |
41 | ThemeSettingProvider {
42 | GPTMobileTheme(
43 | dynamicTheme = LocalDynamicTheme.current,
44 | themeMode = LocalThemeMode.current
45 | ) {
46 | SetupNavGraph(navController)
47 | }
48 | }
49 | }
50 | }
51 |
52 | private fun NavHostController.checkForExistingSettings() {
53 | lifecycleScope.launch {
54 | repeatOnLifecycle(Lifecycle.State.CREATED) {
55 | mainViewModel.event.collect { event ->
56 | if (event == MainViewModel.SplashEvent.OpenIntro) {
57 | navigate(Route.GET_STARTED) {
58 | popUpTo(Route.CHAT_LIST) { inclusive = true }
59 | }
60 | }
61 | }
62 | }
63 | }
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/presentation/ui/main/MainViewModel.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.presentation.ui.main
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.viewModelScope
5 | import dagger.hilt.android.lifecycle.HiltViewModel
6 | import dev.chungjungsoo.gptmobile.data.repository.SettingRepository
7 | import javax.inject.Inject
8 | import kotlinx.coroutines.flow.MutableSharedFlow
9 | import kotlinx.coroutines.flow.MutableStateFlow
10 | import kotlinx.coroutines.flow.SharedFlow
11 | import kotlinx.coroutines.flow.StateFlow
12 | import kotlinx.coroutines.flow.asSharedFlow
13 | import kotlinx.coroutines.flow.asStateFlow
14 | import kotlinx.coroutines.flow.update
15 | import kotlinx.coroutines.launch
16 |
17 | @HiltViewModel
18 | class MainViewModel @Inject constructor(private val settingRepository: SettingRepository) : ViewModel() {
19 |
20 | sealed class SplashEvent {
21 | data object OpenIntro : SplashEvent()
22 | data object OpenHome : SplashEvent()
23 | }
24 |
25 | private val _isReady: MutableStateFlow = MutableStateFlow(false)
26 | val isReady: StateFlow = _isReady.asStateFlow()
27 |
28 | private val _event: MutableSharedFlow = MutableSharedFlow()
29 | val event: SharedFlow = _event.asSharedFlow()
30 |
31 | init {
32 | viewModelScope.launch {
33 | val platforms = settingRepository.fetchPlatforms()
34 |
35 | if (platforms.all { it.enabled.not() }) {
36 | // Initialize
37 | sendSplashEvent(SplashEvent.OpenIntro)
38 | } else {
39 | sendSplashEvent(SplashEvent.OpenHome)
40 | }
41 |
42 | setAsReady()
43 | }
44 | }
45 |
46 | private suspend fun sendSplashEvent(event: SplashEvent) {
47 | _event.emit(event)
48 | }
49 |
50 | private fun setAsReady() {
51 | _isReady.update { true }
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/presentation/ui/setting/LicenseScreen.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.presentation.ui.setting
2 |
3 | import androidx.compose.foundation.layout.Column
4 | import androidx.compose.foundation.layout.fillMaxSize
5 | import androidx.compose.foundation.layout.padding
6 | import androidx.compose.material.icons.Icons
7 | import androidx.compose.material.icons.automirrored.filled.ArrowBack
8 | import androidx.compose.material3.ExperimentalMaterial3Api
9 | import androidx.compose.material3.Icon
10 | import androidx.compose.material3.IconButton
11 | import androidx.compose.material3.LargeTopAppBar
12 | import androidx.compose.material3.MaterialTheme
13 | import androidx.compose.material3.Scaffold
14 | import androidx.compose.material3.Text
15 | import androidx.compose.material3.TopAppBarDefaults
16 | import androidx.compose.material3.TopAppBarScrollBehavior
17 | import androidx.compose.runtime.Composable
18 | import androidx.compose.ui.Modifier
19 | import androidx.compose.ui.input.nestedscroll.nestedScroll
20 | import androidx.compose.ui.res.stringResource
21 | import androidx.compose.ui.text.style.TextOverflow
22 | import androidx.compose.ui.unit.dp
23 | import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
24 | import dev.chungjungsoo.gptmobile.R
25 |
26 | @OptIn(ExperimentalMaterial3Api::class)
27 | @Composable
28 | fun LicenseScreen(
29 | onNavigationClick: () -> Unit
30 | ) {
31 | val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
32 |
33 | Scaffold(
34 | modifier = Modifier
35 | .nestedScroll(scrollBehavior.nestedScrollConnection),
36 | topBar = {
37 | LicenseTopAppBar(onNavigationClick, scrollBehavior)
38 | }
39 | ) { innerPadding ->
40 | Column(
41 | modifier = Modifier.padding(innerPadding)
42 | ) {
43 | LibrariesContainer(modifier = Modifier.fillMaxSize())
44 | }
45 | }
46 | }
47 |
48 | @Composable
49 | @OptIn(ExperimentalMaterial3Api::class)
50 | private fun LicenseTopAppBar(
51 | onNavigationClick: () -> Unit,
52 | scrollBehavior: TopAppBarScrollBehavior
53 | ) {
54 | LargeTopAppBar(
55 | colors = TopAppBarDefaults.topAppBarColors(
56 | containerColor = MaterialTheme.colorScheme.background,
57 | titleContentColor = MaterialTheme.colorScheme.onBackground
58 | ),
59 | title = {
60 | Text(
61 | modifier = Modifier.padding(4.dp),
62 | text = stringResource(R.string.license),
63 | maxLines = 1,
64 | overflow = TextOverflow.Ellipsis
65 | )
66 | },
67 | navigationIcon = {
68 | IconButton(
69 | modifier = Modifier.padding(4.dp),
70 | onClick = onNavigationClick
71 | ) {
72 | Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.go_back))
73 | }
74 | },
75 | scrollBehavior = scrollBehavior
76 | )
77 | }
78 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/presentation/ui/setting/SettingViewModel.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.presentation.ui.setting
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.viewModelScope
5 | import dagger.hilt.android.lifecycle.HiltViewModel
6 | import dev.chungjungsoo.gptmobile.data.dto.Platform
7 | import dev.chungjungsoo.gptmobile.data.model.ApiType
8 | import dev.chungjungsoo.gptmobile.data.repository.SettingRepository
9 | import javax.inject.Inject
10 | import kotlinx.coroutines.flow.MutableStateFlow
11 | import kotlinx.coroutines.flow.StateFlow
12 | import kotlinx.coroutines.flow.asStateFlow
13 | import kotlinx.coroutines.flow.update
14 | import kotlinx.coroutines.launch
15 |
16 | @HiltViewModel
17 | class SettingViewModel @Inject constructor(
18 | private val settingRepository: SettingRepository
19 | ) : ViewModel() {
20 |
21 | private val _platformState = MutableStateFlow(listOf())
22 | val platformState: StateFlow> = _platformState.asStateFlow()
23 |
24 | private val _dialogState = MutableStateFlow(DialogState())
25 | val dialogState: StateFlow = _dialogState.asStateFlow()
26 |
27 | init {
28 | fetchPlatformStatus()
29 | }
30 |
31 | fun toggleAPI(apiType: ApiType) {
32 | val index = _platformState.value.indexOfFirst { it.name == apiType }
33 |
34 | if (index >= 0) {
35 | _platformState.update {
36 | it.mapIndexed { i, p ->
37 | if (index == i) {
38 | p.copy(enabled = p.enabled.not())
39 | } else {
40 | p
41 | }
42 | }
43 | }
44 | viewModelScope.launch {
45 | settingRepository.updatePlatforms(_platformState.value)
46 | }
47 | }
48 | }
49 |
50 | fun savePlatformSettings() {
51 | viewModelScope.launch {
52 | settingRepository.updatePlatforms(_platformState.value)
53 | }
54 | }
55 |
56 | fun updateURL(apiType: ApiType, url: String) {
57 | val index = _platformState.value.indexOfFirst { it.name == apiType }
58 |
59 | if (index >= 0) {
60 | _platformState.update {
61 | it.mapIndexed { i, p ->
62 | if (index == i && url.isNotBlank()) {
63 | p.copy(apiUrl = url)
64 | } else {
65 | p
66 | }
67 | }
68 | }
69 | }
70 | }
71 |
72 | fun updateToken(apiType: ApiType, token: String) {
73 | val index = _platformState.value.indexOfFirst { it.name == apiType }
74 |
75 | if (index >= 0) {
76 | _platformState.update {
77 | it.mapIndexed { i, p ->
78 | if (index == i && token.isNotBlank()) {
79 | p.copy(token = token)
80 | } else {
81 | p
82 | }
83 | }
84 | }
85 | }
86 | }
87 |
88 | fun updateModel(apiType: ApiType, model: String) {
89 | val index = _platformState.value.indexOfFirst { it.name == apiType }
90 |
91 | if (index >= 0) {
92 | _platformState.update {
93 | it.mapIndexed { i, p ->
94 | if (index == i) {
95 | p.copy(model = model)
96 | } else {
97 | p
98 | }
99 | }
100 | }
101 | }
102 | }
103 |
104 | fun updateTemperature(apiType: ApiType, temperature: Float) {
105 | val index = _platformState.value.indexOfFirst { it.name == apiType }
106 | val modifiedTemperature = when (apiType) {
107 | ApiType.ANTHROPIC -> temperature.coerceIn(0F, 1F)
108 | else -> temperature.coerceIn(0F, 2F)
109 | }
110 |
111 | if (index >= 0) {
112 | _platformState.update {
113 | it.mapIndexed { i, p ->
114 | if (index == i) {
115 | p.copy(temperature = modifiedTemperature)
116 | } else {
117 | p
118 | }
119 | }
120 | }
121 | }
122 | }
123 |
124 | fun updateTopP(apiType: ApiType, topP: Float) {
125 | val index = _platformState.value.indexOfFirst { it.name == apiType }
126 | val modifiedTopP = topP.coerceIn(0.1F, 1F)
127 |
128 | if (index >= 0) {
129 | _platformState.update {
130 | it.mapIndexed { i, p ->
131 | if (index == i) {
132 | p.copy(topP = modifiedTopP)
133 | } else {
134 | p
135 | }
136 | }
137 | }
138 | }
139 | }
140 |
141 | fun updateSystemPrompt(apiType: ApiType, prompt: String) {
142 | val index = _platformState.value.indexOfFirst { it.name == apiType }
143 |
144 | if (index >= 0) {
145 | _platformState.update {
146 | it.mapIndexed { i, p ->
147 | if (index == i && prompt.isNotBlank()) {
148 | p.copy(systemPrompt = prompt)
149 | } else {
150 | p
151 | }
152 | }
153 | }
154 | }
155 | }
156 |
157 | fun openThemeDialog() = _dialogState.update { it.copy(isThemeDialogOpen = true) }
158 |
159 | fun openApiUrlDialog() = _dialogState.update { it.copy(isApiUrlDialogOpen = true) }
160 |
161 | fun openApiTokenDialog() = _dialogState.update { it.copy(isApiTokenDialogOpen = true) }
162 |
163 | fun openApiModelDialog() = _dialogState.update { it.copy(isApiModelDialogOpen = true) }
164 |
165 | fun openTemperatureDialog() = _dialogState.update { it.copy(isTemperatureDialogOpen = true) }
166 |
167 | fun openTopPDialog() = _dialogState.update { it.copy(isTopPDialogOpen = true) }
168 |
169 | fun openSystemPromptDialog() = _dialogState.update { it.copy(isSystemPromptDialogOpen = true) }
170 |
171 | fun closeThemeDialog() = _dialogState.update { it.copy(isThemeDialogOpen = false) }
172 |
173 | fun closeApiUrlDialog() = _dialogState.update { it.copy(isApiUrlDialogOpen = false) }
174 |
175 | fun closeApiTokenDialog() = _dialogState.update { it.copy(isApiTokenDialogOpen = false) }
176 |
177 | fun closeApiModelDialog() = _dialogState.update { it.copy(isApiModelDialogOpen = false) }
178 |
179 | fun closeTemperatureDialog() = _dialogState.update { it.copy(isTemperatureDialogOpen = false) }
180 |
181 | fun closeTopPDialog() = _dialogState.update { it.copy(isTopPDialogOpen = false) }
182 |
183 | fun closeSystemPromptDialog() = _dialogState.update { it.copy(isSystemPromptDialogOpen = false) }
184 |
185 | private fun fetchPlatformStatus() {
186 | viewModelScope.launch {
187 | val platforms = settingRepository.fetchPlatforms()
188 | _platformState.update { platforms }
189 | }
190 | }
191 |
192 | data class DialogState(
193 | val isThemeDialogOpen: Boolean = false,
194 | val isApiUrlDialogOpen: Boolean = false,
195 | val isApiTokenDialogOpen: Boolean = false,
196 | val isApiModelDialogOpen: Boolean = false,
197 | val isTemperatureDialogOpen: Boolean = false,
198 | val isTopPDialogOpen: Boolean = false,
199 | val isSystemPromptDialogOpen: Boolean = false
200 | )
201 | }
202 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/presentation/ui/setup/SelectPlatformScreen.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.presentation.ui.setup
2 |
3 | import androidx.compose.foundation.layout.Column
4 | import androidx.compose.foundation.layout.Spacer
5 | import androidx.compose.foundation.layout.fillMaxSize
6 | import androidx.compose.foundation.layout.fillMaxWidth
7 | import androidx.compose.foundation.layout.padding
8 | import androidx.compose.foundation.rememberScrollState
9 | import androidx.compose.foundation.verticalScroll
10 | import androidx.compose.material3.MaterialTheme
11 | import androidx.compose.material3.Scaffold
12 | import androidx.compose.material3.Text
13 | import androidx.compose.runtime.Composable
14 | import androidx.compose.runtime.getValue
15 | import androidx.compose.ui.Modifier
16 | import androidx.compose.ui.res.stringResource
17 | import androidx.compose.ui.semantics.heading
18 | import androidx.compose.ui.semantics.semantics
19 | import androidx.compose.ui.tooling.preview.Preview
20 | import androidx.compose.ui.unit.dp
21 | import androidx.hilt.navigation.compose.hiltViewModel
22 | import dev.chungjungsoo.gptmobile.R
23 | import dev.chungjungsoo.gptmobile.data.dto.Platform
24 | import dev.chungjungsoo.gptmobile.presentation.common.PlatformCheckBoxItem
25 | import dev.chungjungsoo.gptmobile.presentation.common.PrimaryLongButton
26 | import dev.chungjungsoo.gptmobile.presentation.common.Route
27 | import dev.chungjungsoo.gptmobile.util.collectManagedState
28 | import dev.chungjungsoo.gptmobile.util.getPlatformDescriptionResources
29 | import dev.chungjungsoo.gptmobile.util.getPlatformTitleResources
30 |
31 | @Composable
32 | fun SelectPlatformScreen(
33 | modifier: Modifier = Modifier,
34 | setupViewModel: SetupViewModel = hiltViewModel(),
35 | currentRoute: String = Route.SELECT_PLATFORM,
36 | onNavigate: (route: String) -> Unit = {},
37 | onBackAction: () -> Unit
38 | ) {
39 | val platformState by setupViewModel.platformState.collectManagedState()
40 |
41 | Scaffold(
42 | modifier = modifier.fillMaxSize(),
43 | topBar = { SetupAppBar(onBackAction) }
44 | ) { innerPadding ->
45 | Column(
46 | modifier = Modifier
47 | .padding(innerPadding)
48 | .fillMaxSize()
49 | .verticalScroll(rememberScrollState())
50 | ) {
51 | GetStartedText()
52 | SelectPlatform(
53 | platforms = platformState,
54 | onClickEvent = { setupViewModel.updateCheckedState(it) }
55 | )
56 | Spacer(modifier = Modifier.weight(1f))
57 | PrimaryLongButton(
58 | enabled = platformState.any { it.selected },
59 | onClick = {
60 | val nextStep = setupViewModel.getNextSetupRoute(currentRoute)
61 | onNavigate(nextStep)
62 | },
63 | text = stringResource(R.string.next)
64 | )
65 | }
66 | }
67 | }
68 |
69 | @Preview
70 | @Composable
71 | fun GetStartedText(modifier: Modifier = Modifier) {
72 | Column(
73 | modifier = modifier
74 | .fillMaxWidth()
75 | .padding(20.dp)
76 | ) {
77 | Text(
78 | modifier = Modifier
79 | .padding(4.dp)
80 | .semantics { heading() },
81 | text = stringResource(R.string.get_started),
82 | style = MaterialTheme.typography.headlineMedium
83 | )
84 | Text(
85 | modifier = Modifier.padding(4.dp),
86 | text = stringResource(R.string.platform_select_description),
87 | style = MaterialTheme.typography.bodyLarge
88 | )
89 | }
90 | }
91 |
92 | @Composable
93 | fun SelectPlatform(
94 | modifier: Modifier = Modifier,
95 | platforms: List,
96 | onClickEvent: (Platform) -> Unit
97 | ) {
98 | val titles = getPlatformTitleResources()
99 | val descriptions = getPlatformDescriptionResources()
100 |
101 | Column(modifier = modifier) {
102 | platforms.forEach { platform ->
103 | PlatformCheckBoxItem(
104 | platform = platform,
105 | title = titles[platform.name]!!,
106 | description = descriptions[platform.name]!!,
107 | onClickEvent = onClickEvent
108 | )
109 | }
110 | }
111 | }
112 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/presentation/ui/setup/SetupAPIUrlScreen.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.presentation.ui.setup
2 |
3 | import androidx.compose.foundation.clickable
4 | import androidx.compose.foundation.interaction.MutableInteractionSource
5 | import androidx.compose.foundation.layout.Column
6 | import androidx.compose.foundation.layout.Spacer
7 | import androidx.compose.foundation.layout.fillMaxSize
8 | import androidx.compose.foundation.layout.fillMaxWidth
9 | import androidx.compose.foundation.layout.padding
10 | import androidx.compose.foundation.rememberScrollState
11 | import androidx.compose.foundation.text.KeyboardOptions
12 | import androidx.compose.foundation.verticalScroll
13 | import androidx.compose.material.icons.Icons
14 | import androidx.compose.material.icons.outlined.Clear
15 | import androidx.compose.material3.Icon
16 | import androidx.compose.material3.IconButton
17 | import androidx.compose.material3.MaterialTheme
18 | import androidx.compose.material3.OutlinedTextField
19 | import androidx.compose.material3.Scaffold
20 | import androidx.compose.material3.Text
21 | import androidx.compose.runtime.Composable
22 | import androidx.compose.runtime.getValue
23 | import androidx.compose.runtime.remember
24 | import androidx.compose.ui.Modifier
25 | import androidx.compose.ui.platform.LocalFocusManager
26 | import androidx.compose.ui.platform.LocalSoftwareKeyboardController
27 | import androidx.compose.ui.res.stringResource
28 | import androidx.compose.ui.semantics.heading
29 | import androidx.compose.ui.semantics.semantics
30 | import androidx.compose.ui.text.input.ImeAction
31 | import androidx.compose.ui.unit.dp
32 | import androidx.hilt.navigation.compose.hiltViewModel
33 | import dev.chungjungsoo.gptmobile.R
34 | import dev.chungjungsoo.gptmobile.data.dto.Platform
35 | import dev.chungjungsoo.gptmobile.data.model.ApiType
36 | import dev.chungjungsoo.gptmobile.presentation.common.HelpText
37 | import dev.chungjungsoo.gptmobile.presentation.common.PrimaryLongButton
38 | import dev.chungjungsoo.gptmobile.presentation.common.Route
39 | import dev.chungjungsoo.gptmobile.util.collectManagedState
40 | import dev.chungjungsoo.gptmobile.util.getPlatformHelpLinkResources
41 | import dev.chungjungsoo.gptmobile.util.isValidUrl
42 |
43 | @Composable
44 | fun SetupAPIUrlScreen(
45 | modifier: Modifier = Modifier,
46 | currentRoute: String = Route.OLLAMA_API_ADDRESS,
47 | setupViewModel: SetupViewModel = hiltViewModel(),
48 | onNavigate: (route: String) -> Unit,
49 | onBackAction: () -> Unit
50 | ) {
51 | val focusManager = LocalFocusManager.current
52 | val keyboardController = LocalSoftwareKeyboardController.current
53 | val platformState by setupViewModel.platformState.collectManagedState()
54 | val ollamaPlatform = platformState.first { it.name == ApiType.OLLAMA }
55 |
56 | Scaffold(
57 | modifier = modifier.fillMaxSize(),
58 | topBar = { SetupAppBar(onBackAction) }
59 | ) { innerPadding ->
60 | Column(
61 | modifier = Modifier
62 | .padding(innerPadding)
63 | .fillMaxSize()
64 | .verticalScroll(rememberScrollState())
65 | .clickable(
66 | indication = null,
67 | interactionSource = remember { MutableInteractionSource() }
68 | ) {
69 | keyboardController?.hide()
70 | focusManager.clearFocus()
71 | }
72 | ) {
73 | APIAddressInputText()
74 |
75 | APIAddressInput(
76 | platform = ollamaPlatform,
77 | onChangeEvent = { s -> setupViewModel.updateAPIAddress(ollamaPlatform, s) },
78 | onClearEvent = { setupViewModel.updateAPIAddress(ollamaPlatform, "") }
79 | )
80 | Spacer(modifier = Modifier.weight(1f))
81 | PrimaryLongButton(
82 | enabled = ollamaPlatform.apiUrl.isValidUrl() && ollamaPlatform.apiUrl.endsWith("/"),
83 | onClick = {
84 | val nextStep = setupViewModel.getNextSetupRoute(currentRoute)
85 | onNavigate(nextStep)
86 | },
87 | text = stringResource(R.string.next)
88 | )
89 | }
90 | }
91 | }
92 |
93 | @Composable
94 | fun APIAddressInputText(modifier: Modifier = Modifier) {
95 | Column(
96 | modifier = modifier
97 | .fillMaxWidth()
98 | .padding(20.dp)
99 | ) {
100 | Text(
101 | modifier = Modifier
102 | .padding(4.dp)
103 | .semantics { heading() },
104 | text = stringResource(R.string.enter_api_address),
105 | style = MaterialTheme.typography.headlineMedium
106 | )
107 | Text(
108 | modifier = Modifier.padding(4.dp),
109 | text = stringResource(R.string.api_address_description),
110 | style = MaterialTheme.typography.bodyLarge
111 | )
112 | }
113 | }
114 |
115 | @Composable
116 | fun APIAddressInput(
117 | modifier: Modifier = Modifier,
118 | platform: Platform,
119 | onChangeEvent: (String) -> Unit = { _ -> },
120 | onClearEvent: () -> Unit = {}
121 | ) {
122 | val helpLinks = getPlatformHelpLinkResources()
123 |
124 | Column(modifier = modifier) {
125 | OutlinedTextField(
126 | modifier = modifier
127 | .fillMaxWidth()
128 | .padding(top = 16.dp, bottom = 16.dp, start = 20.dp, end = 20.dp),
129 | value = platform.apiUrl,
130 | onValueChange = onChangeEvent,
131 | label = {
132 | Text(stringResource(R.string.ollama_api_address))
133 | },
134 | singleLine = true,
135 | keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
136 | supportingText = {
137 | HelpText(helpLinks[ApiType.OLLAMA]!!)
138 | },
139 | trailingIcon = {
140 | if (platform.apiUrl.isNotBlank()) {
141 | IconButton(onClick = onClearEvent) {
142 | Icon(Icons.Outlined.Clear, contentDescription = stringResource(R.string.clear_token))
143 | }
144 | }
145 | }
146 | )
147 | }
148 | }
149 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/presentation/ui/setup/SetupAppBar.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.presentation.ui.setup
2 |
3 | import androidx.compose.material.icons.Icons
4 | import androidx.compose.material.icons.automirrored.filled.ArrowBack
5 | import androidx.compose.material3.ExperimentalMaterial3Api
6 | import androidx.compose.material3.Icon
7 | import androidx.compose.material3.IconButton
8 | import androidx.compose.material3.TopAppBar
9 | import androidx.compose.runtime.Composable
10 | import androidx.compose.ui.res.stringResource
11 | import dev.chungjungsoo.gptmobile.R
12 |
13 | @OptIn(ExperimentalMaterial3Api::class)
14 | @Composable
15 | fun SetupAppBar(
16 | backAction: () -> Unit
17 | ) {
18 | TopAppBar(
19 | title = { },
20 | navigationIcon = {
21 | IconButton(onClick = backAction) {
22 | Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.go_back))
23 | }
24 | }
25 | )
26 | }
27 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/presentation/ui/setup/SetupCompleteScreen.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.presentation.ui.setup
2 |
3 | import androidx.compose.foundation.Image
4 | import androidx.compose.foundation.layout.Column
5 | import androidx.compose.foundation.layout.Spacer
6 | import androidx.compose.foundation.layout.fillMaxSize
7 | import androidx.compose.foundation.layout.fillMaxWidth
8 | import androidx.compose.foundation.layout.heightIn
9 | import androidx.compose.foundation.layout.padding
10 | import androidx.compose.foundation.layout.widthIn
11 | import androidx.compose.foundation.rememberScrollState
12 | import androidx.compose.foundation.verticalScroll
13 | import androidx.compose.material3.MaterialTheme
14 | import androidx.compose.material3.Scaffold
15 | import androidx.compose.material3.Text
16 | import androidx.compose.runtime.Composable
17 | import androidx.compose.ui.Modifier
18 | import androidx.compose.ui.platform.LocalConfiguration
19 | import androidx.compose.ui.res.stringResource
20 | import androidx.compose.ui.semantics.heading
21 | import androidx.compose.ui.semantics.semantics
22 | import androidx.compose.ui.tooling.preview.Preview
23 | import androidx.compose.ui.unit.dp
24 | import androidx.hilt.navigation.compose.hiltViewModel
25 | import dev.chungjungsoo.gptmobile.R
26 | import dev.chungjungsoo.gptmobile.presentation.common.PrimaryLongButton
27 | import dev.chungjungsoo.gptmobile.presentation.common.Route
28 | import dev.chungjungsoo.gptmobile.presentation.icons.Done
29 |
30 | @Composable
31 | fun SetupCompleteScreen(
32 | modifier: Modifier = Modifier,
33 | currentRoute: String = Route.SETUP_COMPLETE,
34 | setupViewModel: SetupViewModel = hiltViewModel(),
35 | onNavigate: (route: String) -> Unit,
36 | onBackAction: () -> Unit
37 | ) {
38 | val configuration = LocalConfiguration.current
39 | val screenWidth = configuration.screenWidthDp.dp
40 |
41 | Scaffold(
42 | modifier = modifier.fillMaxSize(),
43 | topBar = { SetupAppBar(onBackAction) }
44 | ) { innerPadding ->
45 | Column(
46 | modifier = modifier
47 | .padding(innerPadding)
48 | .fillMaxSize()
49 | .verticalScroll(rememberScrollState())
50 | ) {
51 | SetupCompleteText()
52 | SetupCompleteLogo(
53 | Modifier
54 | .widthIn(min = screenWidth)
55 | .heightIn(min = screenWidth)
56 | .padding(screenWidth * 0.1f)
57 | )
58 | Spacer(modifier = Modifier.weight(1f))
59 | PrimaryLongButton(
60 | onClick = {
61 | setupViewModel.savePlatformState()
62 | val nextStep = setupViewModel.getNextSetupRoute(currentRoute)
63 | onNavigate(nextStep)
64 | },
65 | text = stringResource(R.string.done)
66 | )
67 | }
68 | }
69 | }
70 |
71 | @Preview
72 | @Composable
73 | private fun SetupCompleteText(modifier: Modifier = Modifier) {
74 | Column(
75 | modifier = modifier
76 | .fillMaxWidth()
77 | .padding(20.dp)
78 | ) {
79 | Text(
80 | modifier = Modifier
81 | .padding(4.dp)
82 | .semantics { heading() },
83 | text = stringResource(R.string.setup_complete),
84 | style = MaterialTheme.typography.headlineMedium
85 | )
86 | Text(
87 | modifier = Modifier.padding(4.dp),
88 | text = stringResource(R.string.setup_complete_description),
89 | style = MaterialTheme.typography.bodyLarge
90 | )
91 | }
92 | }
93 |
94 | @Preview
95 | @Composable
96 | private fun SetupCompleteLogo(modifier: Modifier = Modifier) {
97 | Image(
98 | imageVector = Done,
99 | contentDescription = stringResource(R.string.setup_complete_logo),
100 | modifier = modifier
101 | .padding(64.dp)
102 | )
103 | }
104 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/presentation/ui/setup/SetupViewModel.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.presentation.ui.setup
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.viewModelScope
5 | import dagger.hilt.android.lifecycle.HiltViewModel
6 | import dev.chungjungsoo.gptmobile.data.ModelConstants.anthropicModels
7 | import dev.chungjungsoo.gptmobile.data.ModelConstants.googleModels
8 | import dev.chungjungsoo.gptmobile.data.ModelConstants.ollamaModels
9 | import dev.chungjungsoo.gptmobile.data.ModelConstants.openaiModels
10 | import dev.chungjungsoo.gptmobile.data.dto.Platform
11 | import dev.chungjungsoo.gptmobile.data.model.ApiType
12 | import dev.chungjungsoo.gptmobile.data.repository.SettingRepository
13 | import dev.chungjungsoo.gptmobile.presentation.common.Route
14 | import javax.inject.Inject
15 | import kotlinx.coroutines.flow.MutableStateFlow
16 | import kotlinx.coroutines.flow.StateFlow
17 | import kotlinx.coroutines.flow.asStateFlow
18 | import kotlinx.coroutines.flow.update
19 | import kotlinx.coroutines.launch
20 |
21 | @HiltViewModel
22 | class SetupViewModel @Inject constructor(private val settingRepository: SettingRepository) : ViewModel() {
23 |
24 | private val _platformState = MutableStateFlow(
25 | listOf(
26 | Platform(ApiType.OPENAI),
27 | Platform(ApiType.ANTHROPIC),
28 | Platform(ApiType.GOOGLE),
29 | Platform(ApiType.OLLAMA)
30 | )
31 | )
32 | val platformState: StateFlow> = _platformState.asStateFlow()
33 |
34 | fun updateAPIAddress(platform: Platform, address: String) {
35 | val index = _platformState.value.indexOf(platform)
36 |
37 | if (index >= 0) {
38 | _platformState.update {
39 | it.mapIndexed { i, p ->
40 | if (index == i) {
41 | p.copy(apiUrl = address.trim())
42 | } else {
43 | p
44 | }
45 | }
46 | }
47 | }
48 | }
49 |
50 | fun updateCheckedState(platform: Platform) {
51 | val index = _platformState.value.indexOf(platform)
52 |
53 | if (index >= 0) {
54 | _platformState.update {
55 | it.mapIndexed { i, p ->
56 | if (index == i) {
57 | p.copy(selected = p.selected.not())
58 | } else {
59 | p
60 | }
61 | }
62 | }
63 | }
64 | }
65 |
66 | fun updateToken(platform: Platform, token: String) {
67 | val index = _platformState.value.indexOf(platform)
68 |
69 | if (index >= 0) {
70 | _platformState.update {
71 | it.mapIndexed { i, p ->
72 | if (index == i) {
73 | p.copy(token = token.ifBlank { null })
74 | } else {
75 | p
76 | }
77 | }
78 | }
79 | }
80 | }
81 |
82 | fun updateModel(apiType: ApiType, model: String) {
83 | val index = _platformState.value.indexOfFirst { it.name == apiType }
84 |
85 | if (index >= 0) {
86 | _platformState.update {
87 | it.mapIndexed { i, p ->
88 | if (index == i) {
89 | p.copy(model = model)
90 | } else {
91 | p
92 | }
93 | }
94 | }
95 | }
96 | }
97 |
98 | fun savePlatformState() {
99 | _platformState.update { platforms ->
100 | // Update to platform enabled value
101 | platforms.map { p ->
102 | p.copy(enabled = p.selected, selected = false)
103 | }
104 | }
105 | viewModelScope.launch {
106 | settingRepository.updatePlatforms(_platformState.value)
107 | }
108 | }
109 |
110 | fun getNextSetupRoute(currentRoute: String?): String {
111 | val steps = listOf(
112 | Route.SELECT_PLATFORM,
113 | Route.TOKEN_INPUT,
114 | Route.OPENAI_MODEL_SELECT,
115 | Route.ANTHROPIC_MODEL_SELECT,
116 | Route.GOOGLE_MODEL_SELECT,
117 | Route.OLLAMA_MODEL_SELECT,
118 | Route.OLLAMA_API_ADDRESS,
119 | Route.SETUP_COMPLETE
120 | )
121 | val commonSteps = mutableSetOf(Route.SELECT_PLATFORM, Route.TOKEN_INPUT, Route.SETUP_COMPLETE)
122 | val platformStep = mapOf(
123 | Route.OPENAI_MODEL_SELECT to ApiType.OPENAI,
124 | Route.ANTHROPIC_MODEL_SELECT to ApiType.ANTHROPIC,
125 | Route.GOOGLE_MODEL_SELECT to ApiType.GOOGLE,
126 | Route.OLLAMA_MODEL_SELECT to ApiType.OLLAMA,
127 | Route.OLLAMA_API_ADDRESS to ApiType.OLLAMA
128 | )
129 |
130 | val currentIndex = steps.indexOfFirst { it == currentRoute }
131 | val enabledPlatform = platformState.value.filter { it.selected }.map { it.name }.toSet()
132 |
133 | if (enabledPlatform.size == 1 && ApiType.OLLAMA in enabledPlatform) {
134 | // Skip API Token input page
135 | commonSteps.remove(Route.TOKEN_INPUT)
136 | }
137 |
138 | val remainingSteps = steps.filterIndexed { index, setupStep ->
139 | index > currentIndex &&
140 | (setupStep in commonSteps || platformStep[setupStep] in enabledPlatform)
141 | }
142 |
143 | if (remainingSteps.isEmpty()) {
144 | // Setup Complete
145 | return Route.CHAT_LIST
146 | }
147 |
148 | return remainingSteps.first()
149 | }
150 |
151 | fun setDefaultModel(apiType: ApiType, defaultModelIndex: Int): String {
152 | val modelList = when (apiType) {
153 | ApiType.OPENAI -> openaiModels
154 | ApiType.ANTHROPIC -> anthropicModels
155 | ApiType.GOOGLE -> googleModels
156 | ApiType.OLLAMA -> ollamaModels
157 | }.toList()
158 |
159 | if (modelList.size <= defaultModelIndex) {
160 | return ""
161 | }
162 |
163 | val model = modelList[defaultModelIndex]
164 | updateModel(apiType, model)
165 |
166 | return model
167 | }
168 | }
169 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/presentation/ui/setup/TokenInputScreen.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.presentation.ui.setup
2 |
3 | import androidx.compose.foundation.clickable
4 | import androidx.compose.foundation.interaction.MutableInteractionSource
5 | import androidx.compose.foundation.layout.Column
6 | import androidx.compose.foundation.layout.Spacer
7 | import androidx.compose.foundation.layout.fillMaxSize
8 | import androidx.compose.foundation.layout.fillMaxWidth
9 | import androidx.compose.foundation.layout.padding
10 | import androidx.compose.foundation.rememberScrollState
11 | import androidx.compose.foundation.text.KeyboardOptions
12 | import androidx.compose.foundation.verticalScroll
13 | import androidx.compose.material3.MaterialTheme
14 | import androidx.compose.material3.Scaffold
15 | import androidx.compose.material3.Text
16 | import androidx.compose.runtime.Composable
17 | import androidx.compose.runtime.getValue
18 | import androidx.compose.runtime.remember
19 | import androidx.compose.ui.Modifier
20 | import androidx.compose.ui.platform.LocalFocusManager
21 | import androidx.compose.ui.platform.LocalSoftwareKeyboardController
22 | import androidx.compose.ui.res.stringResource
23 | import androidx.compose.ui.semantics.heading
24 | import androidx.compose.ui.semantics.semantics
25 | import androidx.compose.ui.text.input.ImeAction
26 | import androidx.compose.ui.tooling.preview.Preview
27 | import androidx.compose.ui.unit.dp
28 | import androidx.hilt.navigation.compose.hiltViewModel
29 | import dev.chungjungsoo.gptmobile.R
30 | import dev.chungjungsoo.gptmobile.data.dto.Platform
31 | import dev.chungjungsoo.gptmobile.data.model.ApiType
32 | import dev.chungjungsoo.gptmobile.presentation.common.PrimaryLongButton
33 | import dev.chungjungsoo.gptmobile.presentation.common.Route
34 | import dev.chungjungsoo.gptmobile.presentation.common.TokenInputField
35 | import dev.chungjungsoo.gptmobile.util.collectManagedState
36 | import dev.chungjungsoo.gptmobile.util.getPlatformAPILabelResources
37 | import dev.chungjungsoo.gptmobile.util.getPlatformHelpLinkResources
38 |
39 | @Composable
40 | fun TokenInputScreen(
41 | modifier: Modifier = Modifier,
42 | currentRoute: String = Route.TOKEN_INPUT,
43 | setupViewModel: SetupViewModel = hiltViewModel(),
44 | onNavigate: (route: String) -> Unit,
45 | onBackAction: () -> Unit
46 | ) {
47 | val focusManager = LocalFocusManager.current
48 | val keyboardController = LocalSoftwareKeyboardController.current
49 | val platformState by setupViewModel.platformState.collectManagedState()
50 |
51 | Scaffold(
52 | modifier = modifier.fillMaxSize(),
53 | topBar = { SetupAppBar(onBackAction) }
54 | ) { innerPadding ->
55 | Column(
56 | modifier = Modifier
57 | .padding(innerPadding)
58 | .fillMaxSize()
59 | .verticalScroll(rememberScrollState())
60 | .clickable(
61 | indication = null,
62 | interactionSource = remember { MutableInteractionSource() }
63 | ) {
64 | keyboardController?.hide()
65 | focusManager.clearFocus()
66 | }
67 | ) {
68 | TokenInputText()
69 | TokenInput(
70 | platforms = platformState,
71 | onChangeEvent = { platform, s -> setupViewModel.updateToken(platform, s) },
72 | onClearEvent = { platform -> setupViewModel.updateToken(platform, "") }
73 | )
74 | Spacer(modifier = Modifier.weight(1f))
75 | PrimaryLongButton(
76 | enabled = platformState.filter { it.selected && it.name != ApiType.OLLAMA }.all { platform -> platform.token != null },
77 | onClick = {
78 | val nextStep = setupViewModel.getNextSetupRoute(currentRoute)
79 | onNavigate(nextStep)
80 | },
81 | text = stringResource(R.string.next)
82 | )
83 | }
84 | }
85 | }
86 |
87 | @Preview
88 | @Composable
89 | fun TokenInputText(modifier: Modifier = Modifier) {
90 | Column(
91 | modifier = modifier
92 | .fillMaxWidth()
93 | .padding(20.dp)
94 | ) {
95 | Text(
96 | modifier = Modifier
97 | .padding(4.dp)
98 | .semantics { heading() },
99 | text = stringResource(R.string.enter_api_key),
100 | style = MaterialTheme.typography.headlineMedium
101 | )
102 | Text(
103 | modifier = Modifier.padding(4.dp),
104 | text = stringResource(R.string.token_input_description),
105 | style = MaterialTheme.typography.bodyLarge
106 | )
107 | }
108 | }
109 |
110 | @Preview
111 | @Composable
112 | fun TokenInput(
113 | modifier: Modifier = Modifier,
114 | platforms: List = listOf(),
115 | onChangeEvent: (Platform, String) -> Unit = { _, _ -> },
116 | onClearEvent: (Platform) -> Unit = {}
117 | ) {
118 | val labels = getPlatformAPILabelResources()
119 | val helpLinks = getPlatformHelpLinkResources()
120 |
121 | Column(modifier = modifier) {
122 | // Ollama doesn't currently support api keys
123 | platforms.filter { it.selected && it.name != ApiType.OLLAMA }.forEachIndexed { i, platform ->
124 | val isLast = platforms.filter { it.selected && it.name != ApiType.OLLAMA }.size - 1 == i
125 | TokenInputField(
126 | value = platform.token ?: "",
127 | onValueChange = { onChangeEvent(platform, it) },
128 | onClearClick = { onClearEvent(platform) },
129 | label = labels[platform.name]!!,
130 | keyboardOptions = KeyboardOptions(imeAction = if (isLast) ImeAction.Done else ImeAction.Next),
131 | helpLink = helpLinks[platform.name]!!
132 | )
133 | }
134 | }
135 | }
136 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/presentation/ui/startscreen/StartScreen.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.presentation.ui.startscreen
2 |
3 | import androidx.compose.foundation.Image
4 | import androidx.compose.foundation.layout.Column
5 | import androidx.compose.foundation.layout.Spacer
6 | import androidx.compose.foundation.layout.fillMaxSize
7 | import androidx.compose.foundation.layout.fillMaxWidth
8 | import androidx.compose.foundation.layout.height
9 | import androidx.compose.foundation.layout.padding
10 | import androidx.compose.foundation.rememberScrollState
11 | import androidx.compose.foundation.verticalScroll
12 | import androidx.compose.material3.MaterialTheme
13 | import androidx.compose.material3.Scaffold
14 | import androidx.compose.material3.Text
15 | import androidx.compose.runtime.Composable
16 | import androidx.compose.ui.Alignment
17 | import androidx.compose.ui.Modifier
18 | import androidx.compose.ui.layout.ContentScale
19 | import androidx.compose.ui.res.stringResource
20 | import androidx.compose.ui.semantics.heading
21 | import androidx.compose.ui.semantics.semantics
22 | import androidx.compose.ui.tooling.preview.Preview
23 | import androidx.compose.ui.unit.dp
24 | import dev.chungjungsoo.gptmobile.R
25 | import dev.chungjungsoo.gptmobile.presentation.common.PrimaryLongButton
26 | import dev.chungjungsoo.gptmobile.presentation.icons.GptMobileStartScreen
27 |
28 | @Composable
29 | fun StartScreen(onStartClick: () -> Unit) {
30 | Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
31 | Column(
32 | modifier = Modifier
33 | .padding(innerPadding)
34 | .fillMaxSize()
35 | .verticalScroll(rememberScrollState()),
36 | horizontalAlignment = Alignment.CenterHorizontally
37 | ) {
38 | StartScreenLogo()
39 | Spacer(modifier = Modifier.weight(1f))
40 | WelcomeText()
41 | PrimaryLongButton(
42 | onClick = onStartClick,
43 | text = stringResource(R.string.get_started)
44 | )
45 | }
46 | }
47 | }
48 |
49 | @Preview
50 | @Composable
51 | fun StartScreenLogo(modifier: Modifier = Modifier) {
52 | Image(
53 | imageVector = GptMobileStartScreen,
54 | contentDescription = stringResource(R.string.gpt_mobile_introduction_logo),
55 | contentScale = ContentScale.FillHeight,
56 | modifier = modifier
57 | .padding(top = 50.dp)
58 | .height(400.dp)
59 | )
60 | }
61 |
62 | @Preview
63 | @Composable
64 | fun WelcomeText(modifier: Modifier = Modifier) {
65 | Column(
66 | modifier = modifier
67 | .fillMaxWidth()
68 | .padding(20.dp)
69 | ) {
70 | Text(
71 | modifier = Modifier
72 | .padding(4.dp)
73 | .semantics { heading() },
74 | text = stringResource(R.string.welcome_title),
75 | style = MaterialTheme.typography.headlineMedium
76 | )
77 | Text(
78 | modifier = Modifier.padding(4.dp),
79 | text = stringResource(R.string.welcome_description),
80 | style = MaterialTheme.typography.bodyLarge
81 | )
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/util/DefaultHashMap.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.util
2 |
3 | /**
4 | Small implementation of HashMap, but with default values.
5 | This way the get operator will not throw an error or null.
6 | Inspired by Python collections DefaultDict.
7 | */
8 | open class DefaultHashMap(protected val defaultValueProvider: () -> V) : HashMap() {
9 | override operator fun get(key: K): V {
10 | if (key in this) {
11 | return super.get(key)!!
12 | }
13 |
14 | val defaultValue = defaultValueProvider()
15 | this[key] = defaultValue
16 | return super.get(key)!!
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/util/MapStringResources.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.util
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.ui.res.stringResource
5 | import dev.chungjungsoo.gptmobile.R
6 | import dev.chungjungsoo.gptmobile.data.dto.APIModel
7 | import dev.chungjungsoo.gptmobile.data.model.ApiType
8 | import dev.chungjungsoo.gptmobile.data.model.DynamicTheme
9 | import dev.chungjungsoo.gptmobile.data.model.ThemeMode
10 |
11 | @Composable
12 | fun getPlatformTitleResources(): Map = mapOf(
13 | ApiType.OPENAI to stringResource(R.string.openai),
14 | ApiType.ANTHROPIC to stringResource(R.string.anthropic),
15 | ApiType.GOOGLE to stringResource(R.string.google),
16 | ApiType.OLLAMA to stringResource(R.string.ollama)
17 | )
18 |
19 | @Composable
20 | fun getPlatformDescriptionResources(): Map = mapOf(
21 | ApiType.OPENAI to stringResource(R.string.openai_description),
22 | ApiType.ANTHROPIC to stringResource(R.string.anthropic_description),
23 | ApiType.GOOGLE to stringResource(R.string.google_description),
24 | ApiType.OLLAMA to stringResource(R.string.ollama_description)
25 | )
26 |
27 | @Composable
28 | fun getPlatformAPILabelResources(): Map = mapOf(
29 | ApiType.OPENAI to stringResource(R.string.openai_api_key),
30 | ApiType.ANTHROPIC to stringResource(R.string.anthropic_api_key),
31 | ApiType.GOOGLE to stringResource(R.string.google_api_key),
32 | ApiType.OLLAMA to stringResource(R.string.ollama_api_key)
33 | )
34 |
35 | @Composable
36 | fun getPlatformHelpLinkResources(): Map = mapOf(
37 | ApiType.OPENAI to stringResource(R.string.openai_api_help),
38 | ApiType.ANTHROPIC to stringResource(R.string.anthropic_api_help),
39 | ApiType.GOOGLE to stringResource(R.string.google_api_help),
40 | ApiType.OLLAMA to stringResource(R.string.ollama_api_help)
41 | )
42 |
43 | @Composable
44 | fun generateOpenAIModelList(models: LinkedHashSet) = models.mapIndexed { index, model ->
45 | val (name, description) = when (index) {
46 | 0 -> stringResource(R.string.gpt_4o) to stringResource(R.string.gpt_4o_description)
47 | 1 -> stringResource(R.string.gpt_4o_mini) to stringResource(R.string.gpt_4o_mini_description)
48 | 2 -> stringResource(R.string.gpt_4_turbo) to stringResource(R.string.gpt_4_turbo_description)
49 | 3 -> stringResource(R.string.gpt_4) to stringResource(R.string.gpt_4_description)
50 | else -> "" to ""
51 | }
52 | APIModel(name, description, model)
53 | }
54 |
55 | @Composable
56 | fun generateAnthropicModelList(models: LinkedHashSet) = models.mapIndexed { index, model ->
57 | val (name, description) = when (index) {
58 | 0 -> stringResource(R.string.claude_3_5_sonnet) to stringResource(R.string.claude_3_5_sonnet_description)
59 | 1 -> stringResource(R.string.claude_3_opus) to stringResource(R.string.claude_3_opus_description)
60 | 2 -> stringResource(R.string.claude_3_sonnet) to stringResource(R.string.claude_3_sonnet_description)
61 | 3 -> stringResource(R.string.claude_3_haiku) to stringResource(R.string.claude_3_haiku_description)
62 | else -> "" to ""
63 | }
64 | APIModel(name, description, model)
65 | }
66 |
67 | @Composable
68 | fun generateGoogleModelList(models: LinkedHashSet) = models.mapIndexed { index, model ->
69 | val (name, description) = when (index) {
70 | 0 -> stringResource(R.string.gemini_1_5_pro) to stringResource(R.string.gemini_1_5_pro_description)
71 | 1 -> stringResource(R.string.gemini_1_5_flash) to stringResource(R.string.gemini_1_5_flash_description)
72 | 2 -> stringResource(R.string.gemini_1_0_pro) to stringResource(R.string.gemini_1_0_pro_description)
73 | else -> "" to ""
74 | }
75 | APIModel(name, description, model)
76 | }
77 |
78 | @Composable
79 | fun getAPIModelSelectTitle(apiType: ApiType) = when (apiType) {
80 | ApiType.OPENAI -> stringResource(R.string.select_openai_model)
81 | ApiType.ANTHROPIC -> stringResource(R.string.select_anthropic_model)
82 | ApiType.GOOGLE -> stringResource(R.string.select_google_model)
83 | ApiType.OLLAMA -> stringResource(R.string.select_ollama_model)
84 | }
85 |
86 | @Composable
87 | fun getAPIModelSelectDescription(apiType: ApiType) = when (apiType) {
88 | ApiType.OPENAI -> stringResource(R.string.select_openai_model_description)
89 | ApiType.ANTHROPIC -> stringResource(R.string.select_anthropic_model_description)
90 | ApiType.GOOGLE -> stringResource(R.string.select_google_model_description)
91 | ApiType.OLLAMA -> stringResource(id = R.string.select_ollama_model_description)
92 | }
93 |
94 | @Composable
95 | fun getDynamicThemeTitle(theme: DynamicTheme) = when (theme) {
96 | DynamicTheme.ON -> stringResource(R.string.on)
97 | DynamicTheme.OFF -> stringResource(R.string.off)
98 | }
99 |
100 | @Composable
101 | fun getThemeModeTitle(theme: ThemeMode) = when (theme) {
102 | ThemeMode.SYSTEM -> stringResource(R.string.system_default)
103 | ThemeMode.DARK -> stringResource(R.string.on)
104 | ThemeMode.LIGHT -> stringResource(R.string.off)
105 | }
106 |
107 | @Composable
108 | fun getPlatformSettingTitle(apiType: ApiType) = when (apiType) {
109 | ApiType.OPENAI -> stringResource(R.string.openai_setting)
110 | ApiType.ANTHROPIC -> stringResource(R.string.anthropic_setting)
111 | ApiType.GOOGLE -> stringResource(R.string.google_setting)
112 | ApiType.OLLAMA -> stringResource(R.string.ollama_setting)
113 | }
114 |
115 | @Composable
116 | fun getPlatformSettingDescription(apiType: ApiType) = when (apiType) {
117 | ApiType.OPENAI -> stringResource(R.string.platform_setting_description)
118 | ApiType.ANTHROPIC -> stringResource(R.string.platform_setting_description)
119 | ApiType.GOOGLE -> stringResource(R.string.platform_setting_description)
120 | ApiType.OLLAMA -> stringResource(R.string.platform_setting_description)
121 | }
122 |
123 | @Composable
124 | fun getPlatformAPIBrandText(apiType: ApiType) = when (apiType) {
125 | ApiType.OPENAI -> stringResource(R.string.openai_brand_text)
126 | ApiType.ANTHROPIC -> stringResource(R.string.anthropic_brand_text)
127 | ApiType.GOOGLE -> stringResource(R.string.google_brand_text)
128 | ApiType.OLLAMA -> stringResource(R.string.ollama_brand_text)
129 | }
130 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/util/PinnedExitUntilCollapsedScrollBehavior.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.util
2 |
3 | import androidx.compose.animation.core.AnimationSpec
4 | import androidx.compose.animation.core.AnimationState
5 | import androidx.compose.animation.core.DecayAnimationSpec
6 | import androidx.compose.animation.core.Spring
7 | import androidx.compose.animation.core.animateDecay
8 | import androidx.compose.animation.core.animateTo
9 | import androidx.compose.animation.core.spring
10 | import androidx.compose.animation.rememberSplineBasedDecay
11 | import androidx.compose.material3.ExperimentalMaterial3Api
12 | import androidx.compose.material3.TopAppBarScrollBehavior
13 | import androidx.compose.material3.TopAppBarState
14 | import androidx.compose.material3.rememberTopAppBarState
15 | import androidx.compose.runtime.Composable
16 | import androidx.compose.ui.geometry.Offset
17 | import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
18 | import androidx.compose.ui.input.nestedscroll.NestedScrollSource
19 | import androidx.compose.ui.unit.Velocity
20 | import kotlin.math.abs
21 |
22 | /**
23 | * Special thanks to @BenjyTec: https://stackoverflow.com/a/78538564/8606428
24 | */
25 | @ExperimentalMaterial3Api
26 | @Composable
27 | fun pinnedExitUntilCollapsedScrollBehavior(
28 | state: TopAppBarState = rememberTopAppBarState(),
29 | canScroll: () -> Boolean = { true },
30 | snapAnimationSpec: AnimationSpec? = spring(stiffness = Spring.StiffnessMediumLow),
31 | flingAnimationSpec: DecayAnimationSpec? = rememberSplineBasedDecay()
32 | ): TopAppBarScrollBehavior =
33 | PinnedExitUntilCollapsedScrollBehavior(
34 | state = state,
35 | snapAnimationSpec = snapAnimationSpec,
36 | flingAnimationSpec = flingAnimationSpec,
37 | canScroll = canScroll
38 | )
39 |
40 | @OptIn(ExperimentalMaterial3Api::class)
41 | private class PinnedExitUntilCollapsedScrollBehavior(
42 | override val state: TopAppBarState,
43 | override val snapAnimationSpec: AnimationSpec?,
44 | override val flingAnimationSpec: DecayAnimationSpec?,
45 | val canScroll: () -> Boolean = { true }
46 | ) : TopAppBarScrollBehavior {
47 | override val isPinned: Boolean = true
48 | override var nestedScrollConnection =
49 | object : NestedScrollConnection {
50 | override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
51 | // Don't intercept if scrolling down.
52 | if (!canScroll() || available.y > 0f) return Offset.Zero
53 |
54 | val prevHeightOffset = state.heightOffset
55 | state.heightOffset += available.y
56 | return if (prevHeightOffset != state.heightOffset) {
57 | // We're in the middle of top app bar collapse or expand.
58 | // Consume only the scroll on the Y axis.
59 | available.copy(x = 0f)
60 | } else {
61 | Offset.Zero
62 | }
63 | }
64 |
65 | override fun onPostScroll(
66 | consumed: Offset,
67 | available: Offset,
68 | source: NestedScrollSource
69 | ): Offset {
70 | if (!canScroll()) return Offset.Zero
71 | state.contentOffset += consumed.y
72 |
73 | if (available.y < 0f || consumed.y < 0f) {
74 | // When scrolling up, just update the state's height offset.
75 | val oldHeightOffset = state.heightOffset
76 | state.heightOffset += consumed.y
77 | return Offset(0f, state.heightOffset - oldHeightOffset)
78 | }
79 |
80 | if (consumed.y == 0f && available.y > 0) {
81 | // Reset the total content offset to zero when scrolling all the way down. This
82 | // will eliminate some float precision inaccuracies.
83 | state.contentOffset = 0f
84 | }
85 |
86 | if (available.y > 0f) {
87 | // Adjust the height offset in case the consumed delta Y is less than what was
88 | // recorded as available delta Y in the pre-scroll.
89 | val oldHeightOffset = state.heightOffset
90 | state.heightOffset += available.y
91 | return Offset(0f, state.heightOffset - oldHeightOffset)
92 | }
93 | return Offset.Zero
94 | }
95 |
96 | override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
97 | val superConsumed = super.onPostFling(consumed, available)
98 | return superConsumed + settleAppBar(
99 | state,
100 | available.y,
101 | flingAnimationSpec,
102 | snapAnimationSpec
103 | )
104 | }
105 | }
106 | }
107 |
108 | @OptIn(ExperimentalMaterial3Api::class)
109 | private suspend fun settleAppBar(
110 | state: TopAppBarState,
111 | velocity: Float,
112 | flingAnimationSpec: DecayAnimationSpec?,
113 | snapAnimationSpec: AnimationSpec?
114 | ): Velocity {
115 | // Check if the app bar is completely collapsed/expanded. If so, no need to settle the app bar,
116 | // and just return Zero Velocity.
117 | // Note that we don't check for 0f due to float precision with the collapsedFraction
118 | // calculation.
119 | if (state.collapsedFraction < 0.01f || state.collapsedFraction == 1f) {
120 | return Velocity.Zero
121 | }
122 | var remainingVelocity = velocity
123 | // In case there is an initial velocity that was left after a previous user fling, animate to
124 | // continue the motion to expand or collapse the app bar.
125 | if (flingAnimationSpec != null && abs(velocity) > 1f) {
126 | var lastValue = 0f
127 | AnimationState(
128 | initialValue = 0f,
129 | initialVelocity = velocity
130 | )
131 | .animateDecay(flingAnimationSpec) {
132 | val delta = value - lastValue
133 | val initialHeightOffset = state.heightOffset
134 | state.heightOffset = initialHeightOffset + delta
135 | val consumed = abs(initialHeightOffset - state.heightOffset)
136 | lastValue = value
137 | remainingVelocity = this.velocity
138 | // avoid rounding errors and stop if anything is unconsumed
139 | if (abs(delta - consumed) > 0.5f) this.cancelAnimation()
140 | }
141 | }
142 | // Snap if animation specs were provided.
143 | if (snapAnimationSpec != null) {
144 | if (state.heightOffset < 0 &&
145 | state.heightOffset > state.heightOffsetLimit
146 | ) {
147 | AnimationState(initialValue = state.heightOffset).animateTo(
148 | if (state.collapsedFraction < 0.5f) {
149 | 0f
150 | } else {
151 | state.heightOffsetLimit
152 | },
153 | animationSpec = snapAnimationSpec
154 | ) { state.heightOffset = value }
155 | }
156 | }
157 |
158 | return Velocity(0f, remainingVelocity)
159 | }
160 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/util/ScrollStateSaver.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.util
2 |
3 | import androidx.compose.foundation.ScrollState
4 | import androidx.compose.runtime.saveable.Saver
5 |
6 | val multiScrollStateSaver: Saver, *> = Saver(
7 | save = {
8 | val saver = hashMapOf()
9 | it.forEach { i, scrollState -> saver[i] = scrollState.value }
10 | saver
11 | },
12 | restore = {
13 | val restored = DefaultHashMap({ ScrollState(0) })
14 | it.forEach { i, v -> restored[i] = ScrollState(v) }
15 | restored
16 | }
17 | )
18 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/util/StateFlowExtensions.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.util
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.runtime.State
5 | import androidx.lifecycle.compose.collectAsStateWithLifecycle
6 | import kotlinx.coroutines.flow.StateFlow
7 |
8 | @Composable
9 | fun StateFlow.collectManagedState(): State {
10 | // Remove this when this issue is fixed: https://issuetracker.google.com/issues/336842920
11 | return this.collectAsStateWithLifecycle(
12 | lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/dev/chungjungsoo/gptmobile/util/Strings.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile.util
2 |
3 | import android.util.Patterns
4 | import android.webkit.URLUtil
5 |
6 | fun String.isValidUrl(): Boolean = URLUtil.isValidUrl(this) && Patterns.WEB_URL.matcher(this).matches()
7 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_bug_report.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_chart.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_copy.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_f_droid.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_feedback.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_github.xml:
--------------------------------------------------------------------------------
1 |
6 |
10 |
11 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_gpt_mobile_foreground.xml:
--------------------------------------------------------------------------------
1 |
6 |
10 |
15 |
20 |
25 |
30 |
33 |
36 |
37 |
38 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_gpt_mobile_monochrome_foreground.xml:
--------------------------------------------------------------------------------
1 |
6 |
10 |
15 |
20 |
25 |
30 |
33 |
36 |
37 |
38 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_info.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_instructions.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_key.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_license.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_link.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_model.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_play_store.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_round_arrow_right.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_rounded_chat.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_send.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_temperature.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/splash_icon_inset.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi/ic_gpt_mobile.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/resources.properties:
--------------------------------------------------------------------------------
1 | unqualifiedResLocale=en-US
--------------------------------------------------------------------------------
/app/src/main/res/values-night/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
10 |
11 |
--------------------------------------------------------------------------------
/app/src/main/res/values/ic_gpt_mobile_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #00A67D
4 |
--------------------------------------------------------------------------------
/app/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
12 |
13 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/backup_rules.xml:
--------------------------------------------------------------------------------
1 |
8 |
9 |
13 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/data_extraction_rules.xml:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
12 |
13 |
19 |
--------------------------------------------------------------------------------
/app/src/test/kotlin/dev/chungjungsoo/gptmobile/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package dev.chungjungsoo.gptmobile
2 |
3 | import org.junit.Assert.assertEquals
4 | import org.junit.Test
5 |
6 | /**
7 | * Example local unit test, which will execute on the development machine (host).
8 | *
9 | * See [testing documentation](http://d.android.com/tools/testing).
10 | */
11 | class ExampleUnitTest {
12 | @Test
13 | fun addition_isCorrect() {
14 | assertEquals(4, 2 + 2)
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/build.gradle.kts:
--------------------------------------------------------------------------------
1 | // Top-level build file where you can add configuration options common to all sub-projects/modules.
2 | plugins {
3 | alias(libs.plugins.android.application) apply false
4 | alias(libs.plugins.jetbrains.kotlin.android) apply false
5 | alias(libs.plugins.android.hilt) apply false
6 | alias(libs.plugins.compose.compiler) apply false
7 | alias(libs.plugins.kotlin.ksp) apply false
8 | alias(libs.plugins.kotlin.parcelize) apply false
9 | alias(libs.plugins.auto.license).version(libs.versions.autoLicense) apply false
10 | kotlin(libs.plugins.kotlin.serialization.get().pluginId).version(libs.versions.kotlin).apply(false)
11 | }
12 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 | # IDE (e.g. Android Studio) users:
3 | # Gradle settings configured through the IDE *will override*
4 | # any settings specified in this file.
5 | # For more details on how to configure your build environment visit
6 | # http://www.gradle.org/docs/current/userguide/build_environment.html
7 | # Specifies the JVM arguments used for the daemon process.
8 | # The setting is particularly useful for tweaking memory settings.
9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
10 | # When configured, Gradle will run in incubating parallel mode.
11 | # This option should only be used with decoupled projects. For more details, visit
12 | # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
13 | # org.gradle.parallel=true
14 | # AndroidX package structure to make it clearer which packages are bundled with the
15 | # Android operating system, and which are packaged with your app's APK
16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
17 | android.useAndroidX=true
18 | # Kotlin code style for this project: "official" or "obsolete":
19 | kotlin.code.style=official
20 | # Enables namespacing of each library's R class so that its R class includes only the
21 | # resources declared in the library itself and none from the library's dependencies,
22 | # thereby reducing the size of the R class for that library
23 | android.nonTransitiveRClass=true
24 | android.enableR8.fullMode=true
--------------------------------------------------------------------------------
/gradle/libs.versions.toml:
--------------------------------------------------------------------------------
1 | [versions]
2 | agp = "8.7.1"
3 | autoLicense = "11.2.2"
4 | kotlin = "2.0.20"
5 | coreKtx = "1.13.1"
6 | junit = "4.13.2"
7 | junitVersion = "1.2.1"
8 | espressoCore = "3.6.1"
9 | lifecycleRuntime = "2.8.6"
10 | activityCompose = "1.9.3"
11 | composeBom = "2024.10.00"
12 | datastore = "1.1.1"
13 | gemini = "0.9.0"
14 | hilt = "2.52"
15 | ksp = "2.0.20-1.0.25" # Also change with Kotlin version
16 | ktor = "2.3.12"
17 | androidxHilt = "1.2.0"
18 | navigation = "2.8.3"
19 | markdown = "0.5.4"
20 | openai = "3.8.2"
21 | serialization = "1.7.3" # Should not update due to kotlin version
22 | splashscreen = "1.0.1"
23 | room = "2.6.1"
24 |
25 | [libraries]
26 | androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
27 | junit = { group = "junit", name = "junit", version.ref = "junit" }
28 | androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
29 | androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
30 | androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntime" }
31 | androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
32 | androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
33 | androidx-compose-viewmodel = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntime" }
34 | androidx-datastore = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
35 | androidx-navigation = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" }
36 | androidx-ui = { group = "androidx.compose.ui", name = "ui" }
37 | androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
38 | androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
39 | androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
40 | androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
41 | androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
42 | androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
43 | auto-license-core = { group = "com.mikepenz", name = "aboutlibraries-core", version.ref = "autoLicense" }
44 | auto-license-ui = { group = "com.mikepenz", name = "aboutlibraries-compose-m3", version.ref = "autoLicense" }
45 | compose-markdown = { group = "com.github.jeziellago", name = "compose-markdown", version.ref = "markdown" }
46 | gemini = { group = "com.google.ai.client.generativeai", name = "generativeai", version.ref = "gemini"}
47 | hilt = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
48 | hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
49 | hilt-navigation = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "androidxHilt" }
50 | kotlin-serialization = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" }
51 | ktor-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" }
52 | ktor-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" }
53 | ktor-engine = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" }
54 | ktor-logging = { group = "io.ktor", name = "ktor-client-logging-jvm", version.ref = "ktor" }
55 | ktor-serialization = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" }
56 | openai = { group = "com.aallam.openai", name = "openai-client", version.ref = "openai" }
57 | splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashscreen" }
58 | room = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
59 | room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
60 | room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
61 | androidx-lifecycle-runtime-compose-android = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose-android", version.ref = "lifecycleRuntime" }
62 |
63 | [plugins]
64 | android-application = { id = "com.android.application", version.ref = "agp" }
65 | android-hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
66 | auto-license = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "autoLicense" }
67 | jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
68 | compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
69 | kotlin-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
70 | kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" }
71 | kotlin-serialization = { id = "plugin.serialization", version.ref = "kotlin" }
72 |
73 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chocydar/GPTMobile/e91e61e30b195988f9bf83d0f48eedd38276f267/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Mon May 20 13:06:02 KST 2024
2 | distributionBase=GRADLE_USER_HOME
3 | distributionPath=wrapper/dists
4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
5 | zipStoreBase=GRADLE_USER_HOME
6 | zipStorePath=wrapper/dists
7 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | #
4 | # Copyright 2015 the original author or authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 |
19 | ##############################################################################
20 | ##
21 | ## Gradle start up script for UN*X
22 | ##
23 | ##############################################################################
24 |
25 | # Attempt to set APP_HOME
26 | # Resolve links: $0 may be a link
27 | PRG="$0"
28 | # Need this for relative symlinks.
29 | while [ -h "$PRG" ] ; do
30 | ls=`ls -ld "$PRG"`
31 | link=`expr "$ls" : '.*-> \(.*\)$'`
32 | if expr "$link" : '/.*' > /dev/null; then
33 | PRG="$link"
34 | else
35 | PRG=`dirname "$PRG"`"/$link"
36 | fi
37 | done
38 | SAVED="`pwd`"
39 | cd "`dirname \"$PRG\"`/" >/dev/null
40 | APP_HOME="`pwd -P`"
41 | cd "$SAVED" >/dev/null
42 |
43 | APP_NAME="Gradle"
44 | APP_BASE_NAME=`basename "$0"`
45 |
46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
48 |
49 | # Use the maximum available, or set MAX_FD != -1 to use that value.
50 | MAX_FD="maximum"
51 |
52 | warn () {
53 | echo "$*"
54 | }
55 |
56 | die () {
57 | echo
58 | echo "$*"
59 | echo
60 | exit 1
61 | }
62 |
63 | # OS specific support (must be 'true' or 'false').
64 | cygwin=false
65 | msys=false
66 | darwin=false
67 | nonstop=false
68 | case "`uname`" in
69 | CYGWIN* )
70 | cygwin=true
71 | ;;
72 | Darwin* )
73 | darwin=true
74 | ;;
75 | MINGW* )
76 | msys=true
77 | ;;
78 | NONSTOP* )
79 | nonstop=true
80 | ;;
81 | esac
82 |
83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
84 |
85 |
86 | # Determine the Java command to use to start the JVM.
87 | if [ -n "$JAVA_HOME" ] ; then
88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
89 | # IBM's JDK on AIX uses strange locations for the executables
90 | JAVACMD="$JAVA_HOME/jre/sh/java"
91 | else
92 | JAVACMD="$JAVA_HOME/bin/java"
93 | fi
94 | if [ ! -x "$JAVACMD" ] ; then
95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
96 |
97 | Please set the JAVA_HOME variable in your environment to match the
98 | location of your Java installation."
99 | fi
100 | else
101 | JAVACMD="java"
102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
103 |
104 | Please set the JAVA_HOME variable in your environment to match the
105 | location of your Java installation."
106 | fi
107 |
108 | # Increase the maximum file descriptors if we can.
109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
110 | MAX_FD_LIMIT=`ulimit -H -n`
111 | if [ $? -eq 0 ] ; then
112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
113 | MAX_FD="$MAX_FD_LIMIT"
114 | fi
115 | ulimit -n $MAX_FD
116 | if [ $? -ne 0 ] ; then
117 | warn "Could not set maximum file descriptor limit: $MAX_FD"
118 | fi
119 | else
120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
121 | fi
122 | fi
123 |
124 | # For Darwin, add options to specify how the application appears in the dock
125 | if $darwin; then
126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
127 | fi
128 |
129 | # For Cygwin or MSYS, switch paths to Windows format before running java
130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
133 |
134 | JAVACMD=`cygpath --unix "$JAVACMD"`
135 |
136 | # We build the pattern for arguments to be converted via cygpath
137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
138 | SEP=""
139 | for dir in $ROOTDIRSRAW ; do
140 | ROOTDIRS="$ROOTDIRS$SEP$dir"
141 | SEP="|"
142 | done
143 | OURCYGPATTERN="(^($ROOTDIRS))"
144 | # Add a user-defined pattern to the cygpath arguments
145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
147 | fi
148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
149 | i=0
150 | for arg in "$@" ; do
151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
153 |
154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
156 | else
157 | eval `echo args$i`="\"$arg\""
158 | fi
159 | i=`expr $i + 1`
160 | done
161 | case $i in
162 | 0) set -- ;;
163 | 1) set -- "$args0" ;;
164 | 2) set -- "$args0" "$args1" ;;
165 | 3) set -- "$args0" "$args1" "$args2" ;;
166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
172 | esac
173 | fi
174 |
175 | # Escape application args
176 | save () {
177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
178 | echo " "
179 | }
180 | APP_ARGS=`save "$@"`
181 |
182 | # Collect all arguments for the java command, following the shell quoting and substitution rules
183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
184 |
185 | exec "$JAVACMD" "$@"
186 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%" == "" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%" == "" set DIRNAME=.
29 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if "%ERRORLEVEL%" == "0" goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if "%ERRORLEVEL%"=="0" goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
84 | exit /b 1
85 |
86 | :mainEnd
87 | if "%OS%"=="Windows_NT" endlocal
88 |
89 | :omega
90 |
--------------------------------------------------------------------------------
/images/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chocydar/GPTMobile/e91e61e30b195988f9bf83d0f48eedd38276f267/images/logo.png
--------------------------------------------------------------------------------
/images/screenshots.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chocydar/GPTMobile/e91e61e30b195988f9bf83d0f48eedd38276f267/images/screenshots.png
--------------------------------------------------------------------------------
/metadata/en-US/full_description.txt:
--------------------------------------------------------------------------------
1 | This is an Material3 style chat app that supports answers from multiple LLMs at once.
2 |
3 | Supported Platforms
4 | - OpenAI GPT (GPT-4o, turbo, etc)
5 | - Anthropic Claude (3.5 Sonnet, 3 Opus, etc)
6 | - Google Gemini (1.5 Pro, Flash, etc)
7 | - Ollama (Your own server)
8 |
9 | Local chat history
10 | Chat history is only saved locally. The app only sends to official API servers while chatting. NOT SHARED anywhere else.
11 |
12 | Custom API address and custom model name supported. Also, adjust system prompt, top p, temperature, and more!
13 |
14 | Note that some platforms may not be supported in some countries.
15 |
--------------------------------------------------------------------------------
/metadata/en-US/images/featureGraphic.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chocydar/GPTMobile/e91e61e30b195988f9bf83d0f48eedd38276f267/metadata/en-US/images/featureGraphic.png
--------------------------------------------------------------------------------
/metadata/en-US/images/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chocydar/GPTMobile/e91e61e30b195988f9bf83d0f48eedd38276f267/metadata/en-US/images/icon.png
--------------------------------------------------------------------------------
/metadata/en-US/images/phoneScreenshots/1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chocydar/GPTMobile/e91e61e30b195988f9bf83d0f48eedd38276f267/metadata/en-US/images/phoneScreenshots/1.png
--------------------------------------------------------------------------------
/metadata/en-US/images/phoneScreenshots/2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chocydar/GPTMobile/e91e61e30b195988f9bf83d0f48eedd38276f267/metadata/en-US/images/phoneScreenshots/2.png
--------------------------------------------------------------------------------
/metadata/en-US/images/phoneScreenshots/3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chocydar/GPTMobile/e91e61e30b195988f9bf83d0f48eedd38276f267/metadata/en-US/images/phoneScreenshots/3.png
--------------------------------------------------------------------------------
/metadata/en-US/images/phoneScreenshots/4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chocydar/GPTMobile/e91e61e30b195988f9bf83d0f48eedd38276f267/metadata/en-US/images/phoneScreenshots/4.png
--------------------------------------------------------------------------------
/metadata/en-US/images/phoneScreenshots/5.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chocydar/GPTMobile/e91e61e30b195988f9bf83d0f48eedd38276f267/metadata/en-US/images/phoneScreenshots/5.png
--------------------------------------------------------------------------------
/metadata/en-US/images/phoneScreenshots/6.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chocydar/GPTMobile/e91e61e30b195988f9bf83d0f48eedd38276f267/metadata/en-US/images/phoneScreenshots/6.png
--------------------------------------------------------------------------------
/metadata/en-US/images/phoneScreenshots/7.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chocydar/GPTMobile/e91e61e30b195988f9bf83d0f48eedd38276f267/metadata/en-US/images/phoneScreenshots/7.png
--------------------------------------------------------------------------------
/metadata/en-US/images/phoneScreenshots/8.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chocydar/GPTMobile/e91e61e30b195988f9bf83d0f48eedd38276f267/metadata/en-US/images/phoneScreenshots/8.png
--------------------------------------------------------------------------------
/metadata/en-US/short_description.txt:
--------------------------------------------------------------------------------
1 | Your all in one chat assistant - Chat with multiple LLMs at once!
2 |
--------------------------------------------------------------------------------
/metadata/en-US/title.txt:
--------------------------------------------------------------------------------
1 | GPTMobile
2 |
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | repositories {
3 | google {
4 | content {
5 | includeGroupByRegex("com\\.android.*")
6 | includeGroupByRegex("com\\.google.*")
7 | includeGroupByRegex("androidx.*")
8 | }
9 | }
10 | mavenCentral()
11 | gradlePluginPortal()
12 | }
13 | }
14 | dependencyResolutionManagement {
15 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
16 | repositories {
17 | google()
18 | mavenCentral()
19 | maven { url = uri("https://jitpack.io") }
20 | }
21 | }
22 |
23 | rootProject.name = "GPTMobile"
24 | include(":app")
25 |
--------------------------------------------------------------------------------