├── .editorconfig ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ └── service-request.yml ├── play-badge.png └── workflows │ ├── build.yml │ └── format.yml ├── .gitignore ├── .idea ├── .gitignore ├── .name ├── AndroidProjectSystem.xml ├── appInsightsSettings.xml ├── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml ├── compiler.xml ├── gradle.xml ├── inspectionProfiles │ └── Project_Default.xml ├── kotlinc.xml ├── migrations.xml ├── misc.xml ├── other.xml ├── runConfigurations.xml └── vcs.xml ├── Gemfile ├── LICENSE.md ├── README.md ├── app ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro ├── schemas │ └── dev.itsvic.parceltracker.db.AppDatabase │ │ ├── 1.json │ │ ├── 2.json │ │ ├── 3.json │ │ └── 4.json └── src │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── dev │ │ │ └── itsvic │ │ │ └── parceltracker │ │ │ ├── LogcatDumperActivity.kt │ │ │ ├── MainActivity.kt │ │ │ ├── NotificationWorker.kt │ │ │ ├── Notifications.kt │ │ │ ├── ParcelApplication.kt │ │ │ ├── Settings.kt │ │ │ ├── api │ │ │ ├── AnPostDeliveryService.kt │ │ │ ├── BelpostDeliveryService.kt │ │ │ ├── CainiaoDeliveryService.kt │ │ │ ├── CommonFormats.kt │ │ │ ├── Core.kt │ │ │ ├── DhlDeliveryService.kt │ │ │ ├── DpdGerDeliveryService.kt │ │ │ ├── DpdUkDeliveryService.kt │ │ │ ├── EKartDeliveryService.kt │ │ │ ├── EvriDeliveryService.kt │ │ │ ├── ExampleDeliveryService.kt │ │ │ ├── GLSDeliveryService.kt │ │ │ ├── HermesDeliveryService.kt │ │ │ ├── MagyarPostaDeliveryService.kt │ │ │ ├── NovaPostDeliveryService.kt │ │ │ ├── PacketaDeliveryService.kt │ │ │ ├── PolishPostDeliveryService.kt │ │ │ ├── PostNordDeliveryService.kt │ │ │ ├── PosteItalianeDeliveryService.kt │ │ │ ├── SPXDeliveryService.kt │ │ │ ├── SamedayDeliveryService.kt │ │ │ ├── UPSDeliveryService.kt │ │ │ ├── UkrposhtaDeliveryService.kt │ │ │ └── UniUniDeliveryService.kt │ │ │ ├── db │ │ │ ├── AppDatabase.kt │ │ │ ├── Converters.kt │ │ │ ├── DemoMode.kt │ │ │ ├── Helpers.kt │ │ │ ├── Parcel.kt │ │ │ ├── ParcelHistoryItem.kt │ │ │ └── ParcelStatus.kt │ │ │ ├── misc │ │ │ └── DefaultRegions.kt │ │ │ └── ui │ │ │ ├── components │ │ │ ├── AboutDialog.kt │ │ │ ├── LogcatButton.kt │ │ │ ├── ParcelHistoryItemRow.kt │ │ │ └── ParcelRow.kt │ │ │ ├── theme │ │ │ ├── Color.kt │ │ │ ├── Theme.kt │ │ │ ├── Type.kt │ │ │ └── Values.kt │ │ │ └── views │ │ │ ├── AddEditParcelView.kt │ │ │ ├── HomeView.kt │ │ │ ├── ParcelView.kt │ │ │ └── SettingsView.kt │ └── res │ │ ├── drawable │ │ ├── archive.xml │ │ ├── github.xml │ │ ├── icon_background.xml │ │ ├── icon_foreground.xml │ │ ├── icon_monochrome.xml │ │ ├── license_24px.xml │ │ ├── outline_check_24.xml │ │ ├── outline_error_24.xml │ │ ├── outline_local_shipping_24.xml │ │ ├── outline_other_admission_24.xml │ │ ├── outline_pin_drop_24.xml │ │ ├── outline_question_mark_24.xml │ │ ├── outline_search_24.xml │ │ ├── outline_warehouse_24.xml │ │ ├── package_2.xml │ │ └── volunteer_activism_24px.xml │ │ ├── mipmap-anydpi │ │ └── ic_launcher.xml │ │ ├── resources.properties │ │ ├── values-cs │ │ └── strings.xml │ │ ├── values-de │ │ └── strings.xml │ │ ├── values-hu │ │ └── strings.xml │ │ ├── values-ja │ │ └── strings.xml │ │ ├── values-pl │ │ └── strings.xml │ │ ├── values-sv │ │ └── strings.xml │ │ ├── values-th │ │ └── strings.xml │ │ ├── values-tr │ │ └── strings.xml │ │ ├── values-uk │ │ └── strings.xml │ │ ├── values │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── themes.xml │ │ └── xml │ │ ├── backup_rules.xml │ │ └── data_extraction_rules.xml │ └── test │ └── java │ └── dev │ └── itsvic │ └── parceltracker │ └── api │ └── FormatValidationTest.kt ├── build.gradle.kts ├── fastlane ├── Appfile ├── Fastfile ├── README.md └── metadata │ └── android │ ├── cs-CZ │ ├── full_description.txt │ ├── short_description.txt │ └── title.txt │ ├── en-US │ ├── changelogs │ │ ├── 10000009.txt │ │ ├── 10000010.txt │ │ ├── 10100000.txt │ │ ├── 10200000.txt │ │ └── 10300000.txt │ ├── full_description.txt │ ├── images │ │ ├── featureGraphic.png │ │ ├── icon.png │ │ └── phoneScreenshots │ │ │ ├── 1_en-US.png │ │ │ ├── 2_en-US.png │ │ │ ├── 3_en-US.png │ │ │ ├── 4_en-US.png │ │ │ └── 5_en-US.png │ ├── short_description.txt │ └── title.txt │ ├── hu-HU │ ├── changelogs │ │ ├── 10000009.txt │ │ └── 10000010.txt │ ├── full_description.txt │ ├── images │ │ ├── featureGraphic.png │ │ ├── icon.png │ │ └── phoneScreenshots │ │ │ ├── 1_hu.png │ │ │ ├── 2_hu.png │ │ │ ├── 3_hu.png │ │ │ ├── 4_hu.png │ │ │ └── 5_hu.png │ ├── short_description.txt │ ├── title.txt │ └── video.txt │ └── pl-PL │ ├── changelogs │ ├── 10000010.txt │ ├── 10100000.txt │ ├── 10200000.txt │ └── 10300000.txt │ ├── full_description.txt │ ├── images │ ├── featureGraphic.png │ ├── icon.png │ └── phoneScreenshots │ │ ├── 1_pl.png │ │ ├── 2_pl.png │ │ ├── 3_pl.png │ │ ├── 4_pl.png │ │ └── 5_pl.png │ ├── short_description.txt │ ├── title.txt │ └── video.txt ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── scripts ├── .gitignore ├── ktfmt.sh ├── sort-strings-check.sh ├── sort-strings.sh └── sort-strings.xslt └── settings.gradle.kts /.editorconfig: -------------------------------------------------------------------------------- 1 | # This .editorconfig section approximates ktfmt's formatting rules. You can include it in an 2 | # existing .editorconfig file or use it standalone by copying it to /.editorconfig 3 | # and making sure your editor is set to read settings from .editorconfig files. 4 | # 5 | # It includes editor-specific config options for IntelliJ IDEA. 6 | # 7 | # If any option is wrong, PR are welcome 8 | 9 | [{*.kt,*.kts}] 10 | indent_style = space 11 | insert_final_newline = true 12 | max_line_length = 100 13 | indent_size = 2 14 | ij_continuation_indent_size = 4 15 | ij_java_names_count_to_use_import_on_demand = 9999 16 | ij_kotlin_align_in_columns_case_branch = false 17 | ij_kotlin_align_multiline_binary_operation = false 18 | ij_kotlin_align_multiline_extends_list = false 19 | ij_kotlin_align_multiline_method_parentheses = false 20 | ij_kotlin_align_multiline_parameters = true 21 | ij_kotlin_align_multiline_parameters_in_calls = false 22 | ij_kotlin_allow_trailing_comma = true 23 | ij_kotlin_allow_trailing_comma_on_call_site = true 24 | ij_kotlin_assignment_wrap = normal 25 | ij_kotlin_blank_lines_after_class_header = 0 26 | ij_kotlin_blank_lines_around_block_when_branches = 0 27 | ij_kotlin_blank_lines_before_declaration_with_comment_or_annotation_on_separate_line = 1 28 | ij_kotlin_block_comment_at_first_column = true 29 | ij_kotlin_call_parameters_new_line_after_left_paren = true 30 | ij_kotlin_call_parameters_right_paren_on_new_line = false 31 | ij_kotlin_call_parameters_wrap = on_every_item 32 | ij_kotlin_catch_on_new_line = false 33 | ij_kotlin_class_annotation_wrap = split_into_lines 34 | ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL 35 | ij_kotlin_continuation_indent_for_chained_calls = true 36 | ij_kotlin_continuation_indent_for_expression_bodies = true 37 | ij_kotlin_continuation_indent_in_argument_lists = true 38 | ij_kotlin_continuation_indent_in_elvis = false 39 | ij_kotlin_continuation_indent_in_if_conditions = false 40 | ij_kotlin_continuation_indent_in_parameter_lists = false 41 | ij_kotlin_continuation_indent_in_supertype_lists = false 42 | ij_kotlin_else_on_new_line = false 43 | ij_kotlin_enum_constants_wrap = off 44 | ij_kotlin_extends_list_wrap = normal 45 | ij_kotlin_field_annotation_wrap = split_into_lines 46 | ij_kotlin_finally_on_new_line = false 47 | ij_kotlin_if_rparen_on_new_line = false 48 | ij_kotlin_import_nested_classes = false 49 | ij_kotlin_insert_whitespaces_in_simple_one_line_method = true 50 | ij_kotlin_keep_blank_lines_before_right_brace = 2 51 | ij_kotlin_keep_blank_lines_in_code = 2 52 | ij_kotlin_keep_blank_lines_in_declarations = 2 53 | ij_kotlin_keep_first_column_comment = true 54 | ij_kotlin_keep_indents_on_empty_lines = false 55 | ij_kotlin_keep_line_breaks = true 56 | ij_kotlin_lbrace_on_next_line = false 57 | ij_kotlin_line_comment_add_space = false 58 | ij_kotlin_line_comment_at_first_column = true 59 | ij_kotlin_method_annotation_wrap = split_into_lines 60 | ij_kotlin_method_call_chain_wrap = normal 61 | ij_kotlin_method_parameters_new_line_after_left_paren = true 62 | ij_kotlin_method_parameters_right_paren_on_new_line = true 63 | ij_kotlin_method_parameters_wrap = on_every_item 64 | ij_kotlin_name_count_to_use_star_import = 9999 65 | ij_kotlin_name_count_to_use_star_import_for_members = 9999 66 | ij_kotlin_parameter_annotation_wrap = off 67 | ij_kotlin_space_after_comma = true 68 | ij_kotlin_space_after_extend_colon = true 69 | ij_kotlin_space_after_type_colon = true 70 | ij_kotlin_space_before_catch_parentheses = true 71 | ij_kotlin_space_before_comma = false 72 | ij_kotlin_space_before_extend_colon = true 73 | ij_kotlin_space_before_for_parentheses = true 74 | ij_kotlin_space_before_if_parentheses = true 75 | ij_kotlin_space_before_lambda_arrow = true 76 | ij_kotlin_space_before_type_colon = false 77 | ij_kotlin_space_before_when_parentheses = true 78 | ij_kotlin_space_before_while_parentheses = true 79 | ij_kotlin_spaces_around_additive_operators = true 80 | ij_kotlin_spaces_around_assignment_operators = true 81 | ij_kotlin_spaces_around_equality_operators = true 82 | ij_kotlin_spaces_around_function_type_arrow = true 83 | ij_kotlin_spaces_around_logical_operators = true 84 | ij_kotlin_spaces_around_multiplicative_operators = true 85 | ij_kotlin_spaces_around_range = false 86 | ij_kotlin_spaces_around_relational_operators = true 87 | ij_kotlin_spaces_around_unary_operator = false 88 | ij_kotlin_spaces_around_when_arrow = true 89 | ij_kotlin_variable_annotation_wrap = off 90 | ij_kotlin_while_on_new_line = false 91 | ij_kotlin_wrap_elvis_expressions = 1 92 | ij_kotlin_wrap_expression_body_functions = 1 93 | ij_kotlin_wrap_first_method_in_call_chain = false -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: itsvic-dev 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/service-request.yml: -------------------------------------------------------------------------------- 1 | name: Service request 2 | description: Suggest support for a given service 3 | title: "Support for " 4 | labels: ["enhancement", "service support"] 5 | 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: | 10 | Thank you for taking the time to fill out this form! 11 | 12 | Before creating the issue, please verify that this service [has not been suggested before](https://github.com/itsvic-dev/parcel/issues?q=is%3Aissue%20label%3A%22service%20support%22%20) in another issue. 13 | 14 | - type: checkboxes 15 | id: terms-n-such 16 | attributes: 17 | label: Prerequisites 18 | description: By submitting this issue, you agree that the information you provided is correct and you have done the necessary research beforehand. 19 | options: 20 | - label: The information provided below is, to my knowledge, correct. 21 | required: true 22 | - label: I verify that I have searched through the existing issues and that this issue is not a duplicate. 23 | required: true 24 | 25 | - type: input 26 | id: tracking-page-url 27 | attributes: 28 | label: Tracking page 29 | description: Please provide a link to the tracking page for this service. 30 | placeholder: https://example-post.org/tracking 31 | validations: 32 | required: true 33 | 34 | - type: input 35 | id: dev-docs 36 | attributes: 37 | label: Developer documentation page 38 | description: Please provide a link to the developer documentation for this service, if possible. 39 | placeholder: https://developer.example-post.org 40 | validations: 41 | required: false 42 | 43 | - type: textarea 44 | id: additional-comments 45 | attributes: 46 | label: Additional context 47 | description: Is there anything else we should know? Let us know here. 48 | validations: 49 | required: false 50 | -------------------------------------------------------------------------------- /.github/play-badge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itsvic-dev/parcel/8d8ad384adc3e67f288f894b612c5036cc352ecf/.github/play-badge.png -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | pull_request: 6 | branches: [master] 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout sources 13 | uses: actions/checkout@v4 14 | 15 | - name: Setup Java 16 | uses: actions/setup-java@v4 17 | with: 18 | distribution: "temurin" 19 | java-version: 17 20 | 21 | - name: Setup Gradle 22 | uses: gradle/actions/setup-gradle@v4 23 | 24 | - name: Build 25 | run: ./gradlew assembleRelease 26 | 27 | - name: Run unit tests 28 | run: ./gradlew testRelease 29 | 30 | - uses: actions/upload-artifact@v4 31 | name: Upload APK artifact 32 | with: 33 | name: app-release-unsigned.apk 34 | path: app/build/outputs/apk/release/app-release-unsigned.apk 35 | 36 | - uses: actions/upload-artifact@v4 37 | name: Upload unit test report 38 | if: always() 39 | with: 40 | name: Unit test report 41 | path: app/build/reports/tests/testReleaseUnitTest/ 42 | -------------------------------------------------------------------------------- /.github/workflows/format.yml: -------------------------------------------------------------------------------- 1 | name: Check formatting 2 | 3 | on: 4 | push: 5 | pull_request: 6 | branches: [master] 7 | 8 | jobs: 9 | format: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout sources 13 | uses: actions/checkout@v4 14 | 15 | - name: Setup Java 16 | uses: actions/setup-java@v4 17 | with: 18 | distribution: "temurin" 19 | java-version: 17 20 | 21 | - name: Setup Nix 22 | uses: DeterminateSystems/nix-installer-action@main 23 | 24 | - name: Check Kotlin formatting 25 | run: ./scripts/ktfmt.sh --set-exit-if-changed --dry-run . 26 | 27 | - name: Check if translation files are sorted 28 | run: ./scripts/sort-strings-check.sh 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/deploymentTargetSelector.xml 10 | /.idea/assetWizardSettings.xml 11 | .DS_Store 12 | /build 13 | /captures 14 | .externalNativeBuild 15 | .cxx 16 | local.properties 17 | 18 | /app/release 19 | 20 | fastlane/play-store-creds.json 21 | fastlane/report.xml 22 | 23 | keystore.properties 24 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /.idea/.name: -------------------------------------------------------------------------------- 1 | Parcel -------------------------------------------------------------------------------- /.idea/AndroidProjectSystem.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/appInsightsSettings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 39 | 40 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 119 | 120 | 122 | 123 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 19 | 20 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 61 | -------------------------------------------------------------------------------- /.idea/kotlinc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/migrations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | -------------------------------------------------------------------------------- /.idea/other.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/runConfigurations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 16 | 17 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "fastlane" 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Parcel 2 | 3 | Parcel is an app that lets you track your parcels from various providers with ease. 4 | 5 |

6 | 7 | Get it on Google Play 8 | 9 | 10 | Get it on IzzyOnDroid 11 | 12 |

13 | 14 |

15 | 16 | Join our Discord 17 | 18 | 19 | Join the Matrix room 20 | 21 |

22 | 23 | ## Contributing 24 | 25 | We use `ktfmt` for formatting files. For ease of use, we included the sample editorconfig that comes with `ktfmt`, as well as a helper script to invoke it. 26 | To format all the code, simply run `./scripts/ktfmt.sh .`. It will download `ktfmt` if necessary. 27 | 28 | Similarly, we have `./scripts/sort-strings.sh` to sort translation files by key. This script uses Nix to pull in `xsltproc` from `libxslt`. 29 | 30 | ## Supported services 31 | 32 | International: 33 | - Cainiao 34 | - DHL 35 | - GLS 36 | - UPS 37 | 38 | North America: 39 | - UniUni 40 | 41 | United Kingdom: 42 | - DPD UK 43 | - Evri 44 | 45 | Europe: 46 | - An Post (IE) 47 | - Belpost (BY) 48 | - GLS Hungary 49 | - Hermes (DE) 50 | - Magyar Posta (HU) 51 | - Nova Post (UA) 52 | - Packeta 53 | - Poczta Polska (PL) 54 | - Poste Italiane (IT) 55 | - PostNord 56 | - Sameday Bulgaria 57 | - Sameday Hungary 58 | - Sameday Romania 59 | - Ukrposhta (UA) 60 | 61 | Asia: 62 | - eKart (IN) 63 | - SPX Thailand 64 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | import java.io.FileInputStream 3 | import java.util.Properties 4 | 5 | plugins { 6 | alias(libs.plugins.android.application) 7 | alias(libs.plugins.kotlin.android) 8 | alias(libs.plugins.kotlin.compose) 9 | alias(libs.plugins.ksp) 10 | alias(libs.plugins.room) 11 | 12 | kotlin("plugin.serialization") version "2.0.21" 13 | } 14 | 15 | val keystorePropertiesFile = rootProject.file("keystore.properties") 16 | val keystoreProperties = Properties() 17 | 18 | if (keystorePropertiesFile.exists()) 19 | keystoreProperties.load(FileInputStream(keystorePropertiesFile)) 20 | 21 | room { schemaDirectory("$projectDir/schemas") } 22 | 23 | android { 24 | namespace = "dev.itsvic.parceltracker" 25 | compileSdk = 35 26 | 27 | defaultConfig { 28 | applicationId = "dev.itsvic.parceltracker" 29 | minSdk = 26 30 | targetSdk = 35 31 | // ((major * 100 + minor) * 100 + patch) * 1000 + build 32 | versionCode = 10300000 33 | versionName = "1.3.0" 34 | 35 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 36 | } 37 | 38 | signingConfigs { 39 | if (keystorePropertiesFile.exists()) 40 | create("release") { 41 | keyAlias = keystoreProperties["keyAlias"] as String 42 | keyPassword = keystoreProperties["keyPassword"] as String 43 | storeFile = file(keystoreProperties["storeFile"] as String) 44 | storePassword = keystoreProperties["storePassword"] as String 45 | } 46 | } 47 | 48 | buildTypes { 49 | release { 50 | if (keystorePropertiesFile.exists()) { 51 | signingConfig = signingConfigs.getByName("release") 52 | } 53 | 54 | isMinifyEnabled = true 55 | isShrinkResources = true 56 | proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") 57 | } 58 | 59 | debug { isPseudoLocalesEnabled = true } 60 | } 61 | 62 | androidResources { generateLocaleConfig = true } 63 | 64 | compileOptions { 65 | sourceCompatibility = JavaVersion.VERSION_11 66 | targetCompatibility = JavaVersion.VERSION_11 67 | } 68 | 69 | kotlinOptions { jvmTarget = "11" } 70 | 71 | buildFeatures { 72 | compose = true 73 | buildConfig = true 74 | } 75 | 76 | // Disables encrypted dependency info block as requested by the F-Droid team. 77 | dependenciesInfo { 78 | includeInApk = false 79 | includeInBundle = false 80 | } 81 | } 82 | 83 | dependencies { 84 | implementation(libs.androidx.core.ktx) 85 | implementation(libs.androidx.lifecycle.runtime.ktx) 86 | implementation(libs.androidx.activity.compose) 87 | implementation(platform(libs.androidx.compose.bom)) 88 | implementation(libs.androidx.ui) 89 | implementation(libs.androidx.ui.graphics) 90 | implementation(libs.androidx.ui.tooling.preview) 91 | implementation(libs.androidx.material3) 92 | implementation(libs.androidx.material.icons) 93 | implementation(libs.okhttp) 94 | implementation(libs.okhttp.coroutines) 95 | implementation(libs.logging.interceptor) 96 | implementation(libs.moshi) 97 | implementation(libs.androidx.navigation.compose) 98 | implementation(libs.androidx.navigation.fragment) 99 | implementation(libs.androidx.navigation.ui) 100 | implementation(libs.kotlinx.serialization.json) 101 | implementation(libs.room.runtime) 102 | implementation(libs.room.ktx) 103 | implementation(libs.androidx.datastore.preferences) 104 | implementation(libs.retrofit) 105 | implementation(libs.converter.moshi) 106 | implementation(libs.work.runtime) 107 | implementation(libs.work.runtime.ktx) 108 | implementation(libs.kotlinx.coroutines.guava) 109 | implementation(libs.androidx.browser) 110 | 111 | ksp(libs.room.compiler) 112 | ksp(libs.moshi.kotlin.codegen) 113 | 114 | testImplementation(libs.junit) 115 | androidTestImplementation(libs.androidx.junit) 116 | androidTestImplementation(libs.androidx.espresso.core) 117 | androidTestImplementation(platform(libs.androidx.compose.bom)) 118 | androidTestImplementation(libs.androidx.ui.test.junit4) 119 | debugImplementation(libs.androidx.ui.tooling) 120 | debugImplementation(libs.androidx.ui.test.manifest) 121 | } 122 | -------------------------------------------------------------------------------- /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/schemas/dev.itsvic.parceltracker.db.AppDatabase/1.json: -------------------------------------------------------------------------------- 1 | { 2 | "formatVersion": 1, 3 | "database": { 4 | "version": 1, 5 | "identityHash": "bafe12f1d7f99cdd71bbce6de2c025e5", 6 | "entities": [ 7 | { 8 | "tableName": "Parcel", 9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `humanName` TEXT NOT NULL, `parcelId` TEXT NOT NULL, `postalCode` TEXT, `service` TEXT NOT NULL)", 10 | "fields": [ 11 | { 12 | "fieldPath": "id", 13 | "columnName": "id", 14 | "affinity": "INTEGER", 15 | "notNull": true 16 | }, 17 | { 18 | "fieldPath": "humanName", 19 | "columnName": "humanName", 20 | "affinity": "TEXT", 21 | "notNull": true 22 | }, 23 | { 24 | "fieldPath": "parcelId", 25 | "columnName": "parcelId", 26 | "affinity": "TEXT", 27 | "notNull": true 28 | }, 29 | { 30 | "fieldPath": "postalCode", 31 | "columnName": "postalCode", 32 | "affinity": "TEXT", 33 | "notNull": false 34 | }, 35 | { 36 | "fieldPath": "service", 37 | "columnName": "service", 38 | "affinity": "TEXT", 39 | "notNull": true 40 | } 41 | ], 42 | "primaryKey": { 43 | "autoGenerate": true, 44 | "columnNames": [ 45 | "id" 46 | ] 47 | }, 48 | "indices": [], 49 | "foreignKeys": [] 50 | } 51 | ], 52 | "views": [], 53 | "setupQueries": [ 54 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", 55 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'bafe12f1d7f99cdd71bbce6de2c025e5')" 56 | ] 57 | } 58 | } -------------------------------------------------------------------------------- /app/schemas/dev.itsvic.parceltracker.db.AppDatabase/2.json: -------------------------------------------------------------------------------- 1 | { 2 | "formatVersion": 1, 3 | "database": { 4 | "version": 2, 5 | "identityHash": "8028422756a3c5009d7b5ef0c9082059", 6 | "entities": [ 7 | { 8 | "tableName": "Parcel", 9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `humanName` TEXT NOT NULL, `parcelId` TEXT NOT NULL, `postalCode` TEXT, `service` TEXT NOT NULL)", 10 | "fields": [ 11 | { 12 | "fieldPath": "id", 13 | "columnName": "id", 14 | "affinity": "INTEGER", 15 | "notNull": true 16 | }, 17 | { 18 | "fieldPath": "humanName", 19 | "columnName": "humanName", 20 | "affinity": "TEXT", 21 | "notNull": true 22 | }, 23 | { 24 | "fieldPath": "parcelId", 25 | "columnName": "parcelId", 26 | "affinity": "TEXT", 27 | "notNull": true 28 | }, 29 | { 30 | "fieldPath": "postalCode", 31 | "columnName": "postalCode", 32 | "affinity": "TEXT", 33 | "notNull": false 34 | }, 35 | { 36 | "fieldPath": "service", 37 | "columnName": "service", 38 | "affinity": "TEXT", 39 | "notNull": true 40 | } 41 | ], 42 | "primaryKey": { 43 | "autoGenerate": true, 44 | "columnNames": [ 45 | "id" 46 | ] 47 | }, 48 | "indices": [], 49 | "foreignKeys": [] 50 | }, 51 | { 52 | "tableName": "ParcelStatus", 53 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`parcelId` INTEGER NOT NULL, `status` TEXT NOT NULL, `lastChange` INTEGER NOT NULL, PRIMARY KEY(`parcelId`))", 54 | "fields": [ 55 | { 56 | "fieldPath": "parcelId", 57 | "columnName": "parcelId", 58 | "affinity": "INTEGER", 59 | "notNull": true 60 | }, 61 | { 62 | "fieldPath": "status", 63 | "columnName": "status", 64 | "affinity": "TEXT", 65 | "notNull": true 66 | }, 67 | { 68 | "fieldPath": "lastChange", 69 | "columnName": "lastChange", 70 | "affinity": "INTEGER", 71 | "notNull": true 72 | } 73 | ], 74 | "primaryKey": { 75 | "autoGenerate": false, 76 | "columnNames": [ 77 | "parcelId" 78 | ] 79 | }, 80 | "indices": [], 81 | "foreignKeys": [] 82 | } 83 | ], 84 | "views": [], 85 | "setupQueries": [ 86 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", 87 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8028422756a3c5009d7b5ef0c9082059')" 88 | ] 89 | } 90 | } -------------------------------------------------------------------------------- /app/schemas/dev.itsvic.parceltracker.db.AppDatabase/3.json: -------------------------------------------------------------------------------- 1 | { 2 | "formatVersion": 1, 3 | "database": { 4 | "version": 3, 5 | "identityHash": "c468cb8fb82c841f998cdd3e739d5c30", 6 | "entities": [ 7 | { 8 | "tableName": "Parcel", 9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `humanName` TEXT NOT NULL, `parcelId` TEXT NOT NULL, `postalCode` TEXT, `service` TEXT NOT NULL, `isArchived` INTEGER NOT NULL DEFAULT 0)", 10 | "fields": [ 11 | { 12 | "fieldPath": "id", 13 | "columnName": "id", 14 | "affinity": "INTEGER", 15 | "notNull": true 16 | }, 17 | { 18 | "fieldPath": "humanName", 19 | "columnName": "humanName", 20 | "affinity": "TEXT", 21 | "notNull": true 22 | }, 23 | { 24 | "fieldPath": "parcelId", 25 | "columnName": "parcelId", 26 | "affinity": "TEXT", 27 | "notNull": true 28 | }, 29 | { 30 | "fieldPath": "postalCode", 31 | "columnName": "postalCode", 32 | "affinity": "TEXT", 33 | "notNull": false 34 | }, 35 | { 36 | "fieldPath": "service", 37 | "columnName": "service", 38 | "affinity": "TEXT", 39 | "notNull": true 40 | }, 41 | { 42 | "fieldPath": "isArchived", 43 | "columnName": "isArchived", 44 | "affinity": "INTEGER", 45 | "notNull": true, 46 | "defaultValue": "0" 47 | } 48 | ], 49 | "primaryKey": { 50 | "autoGenerate": true, 51 | "columnNames": [ 52 | "id" 53 | ] 54 | }, 55 | "indices": [], 56 | "foreignKeys": [] 57 | }, 58 | { 59 | "tableName": "ParcelStatus", 60 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`parcelId` INTEGER NOT NULL, `status` TEXT NOT NULL, `lastChange` INTEGER NOT NULL, PRIMARY KEY(`parcelId`))", 61 | "fields": [ 62 | { 63 | "fieldPath": "parcelId", 64 | "columnName": "parcelId", 65 | "affinity": "INTEGER", 66 | "notNull": true 67 | }, 68 | { 69 | "fieldPath": "status", 70 | "columnName": "status", 71 | "affinity": "TEXT", 72 | "notNull": true 73 | }, 74 | { 75 | "fieldPath": "lastChange", 76 | "columnName": "lastChange", 77 | "affinity": "INTEGER", 78 | "notNull": true 79 | } 80 | ], 81 | "primaryKey": { 82 | "autoGenerate": false, 83 | "columnNames": [ 84 | "parcelId" 85 | ] 86 | }, 87 | "indices": [], 88 | "foreignKeys": [] 89 | }, 90 | { 91 | "tableName": "ParcelHistoryItem", 92 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `parcelId` INTEGER NOT NULL, `description` TEXT NOT NULL, `time` TEXT NOT NULL, `location` TEXT NOT NULL)", 93 | "fields": [ 94 | { 95 | "fieldPath": "id", 96 | "columnName": "id", 97 | "affinity": "INTEGER", 98 | "notNull": true 99 | }, 100 | { 101 | "fieldPath": "parcelId", 102 | "columnName": "parcelId", 103 | "affinity": "INTEGER", 104 | "notNull": true 105 | }, 106 | { 107 | "fieldPath": "description", 108 | "columnName": "description", 109 | "affinity": "TEXT", 110 | "notNull": true 111 | }, 112 | { 113 | "fieldPath": "time", 114 | "columnName": "time", 115 | "affinity": "TEXT", 116 | "notNull": true 117 | }, 118 | { 119 | "fieldPath": "location", 120 | "columnName": "location", 121 | "affinity": "TEXT", 122 | "notNull": true 123 | } 124 | ], 125 | "primaryKey": { 126 | "autoGenerate": true, 127 | "columnNames": [ 128 | "id" 129 | ] 130 | }, 131 | "indices": [], 132 | "foreignKeys": [] 133 | } 134 | ], 135 | "views": [], 136 | "setupQueries": [ 137 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", 138 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c468cb8fb82c841f998cdd3e739d5c30')" 139 | ] 140 | } 141 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 20 | 21 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /app/src/main/java/dev/itsvic/parceltracker/LogcatDumperActivity.kt: -------------------------------------------------------------------------------- 1 | package dev.itsvic.parceltracker 2 | 3 | import android.os.Bundle 4 | import android.util.Log 5 | import androidx.activity.ComponentActivity 6 | import androidx.activity.compose.setContent 7 | import androidx.activity.result.contract.ActivityResultContracts 8 | import androidx.compose.foundation.background 9 | import androidx.compose.foundation.layout.Box 10 | import androidx.compose.material3.MaterialTheme 11 | import androidx.compose.material3.Text 12 | import androidx.compose.ui.Modifier 13 | import dev.itsvic.parceltracker.ui.theme.ParcelTrackerTheme 14 | import java.io.BufferedReader 15 | import java.io.FileOutputStream 16 | import java.io.InputStreamReader 17 | 18 | class LogcatDumperActivity : ComponentActivity() { 19 | override fun onCreate(savedInstanceState: Bundle?) { 20 | super.onCreate(savedInstanceState) 21 | 22 | setContent { 23 | ParcelTrackerTheme { 24 | Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.background)) { 25 | Text("Dumping logs, please wait...") 26 | } 27 | } 28 | } 29 | 30 | launcher.launch("parcel-logcat") 31 | } 32 | 33 | private val launcher = 34 | registerForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri -> 35 | Log.d("LogcatDumper", "Got file path $uri, starting...") 36 | val contentResolver = applicationContext.contentResolver 37 | uri?.let { uri -> 38 | contentResolver.openFileDescriptor(uri, "w")?.use { pfd -> 39 | FileOutputStream(pfd.fileDescriptor).bufferedWriter().use { writer -> 40 | val process = Runtime.getRuntime().exec("logcat -d") 41 | BufferedReader(InputStreamReader(process.inputStream)).use { reader -> 42 | reader.copyTo(writer) 43 | } 44 | } 45 | } ?: Log.e("LogcatDumper", "outputStream is null") 46 | } 47 | Log.d("LogcatDumper", "Done.") 48 | finish() 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /app/src/main/java/dev/itsvic/parceltracker/NotificationWorker.kt: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | package dev.itsvic.parceltracker 3 | 4 | import android.content.Context 5 | import android.util.Log 6 | import androidx.work.Constraints 7 | import androidx.work.CoroutineWorker 8 | import androidx.work.ExistingPeriodicWorkPolicy 9 | import androidx.work.NetworkType 10 | import androidx.work.PeriodicWorkRequestBuilder 11 | import androidx.work.WorkInfo 12 | import androidx.work.WorkManager 13 | import androidx.work.WorkerParameters 14 | import dev.itsvic.parceltracker.api.getParcel 15 | import dev.itsvic.parceltracker.db.ParcelStatus 16 | import java.time.ZoneId 17 | import java.util.concurrent.TimeUnit 18 | import kotlinx.coroutines.Dispatchers 19 | import kotlinx.coroutines.flow.first 20 | import kotlinx.coroutines.flow.map 21 | import kotlinx.coroutines.guava.await 22 | import kotlinx.coroutines.withContext 23 | 24 | class NotificationWorker(context: Context, params: WorkerParameters) : 25 | CoroutineWorker(context, params) { 26 | override suspend fun doWork(): Result { 27 | val db = ParcelApplication.db 28 | val parcelDao = db.parcelDao() 29 | val statusDao = db.parcelStatusDao() 30 | val zone = ZoneId.systemDefault() 31 | Log.d("NotificationWorker", "I ran!") 32 | 33 | withContext(Dispatchers.IO) { 34 | val parcels = parcelDao.getAllNonArchivedWithStatusAsync() 35 | Log.d("NotificationWorker", "Got parcels: $parcels") 36 | 37 | for (parcelWithStatus in parcels) { 38 | val parcel = parcelWithStatus.parcel 39 | val oldStatus = parcelWithStatus.status 40 | 41 | Log.d("NotificationWorker", "Fetching parcel status for $parcel") 42 | val apiParcel = 43 | try { 44 | applicationContext.getParcel(parcel.parcelId, parcel.postalCode, parcel.service) 45 | } catch (e: Exception) { 46 | Log.d("NotificationWorker", "Failed to fetch, skipping", e) 47 | continue 48 | } 49 | 50 | val lastChange = apiParcel.history.first().time.atZone(zone).toInstant() 51 | 52 | when { 53 | oldStatus == null -> { 54 | Log.d("NotificationWorker", "Parcel did not have a status before, will only add one.") 55 | statusDao.insert(ParcelStatus(parcel.id, apiParcel.currentStatus, lastChange)) 56 | } 57 | 58 | oldStatus.lastChange != lastChange -> { 59 | Log.d("NotificationWorker", "Parcel has had updates since then, push a notification!") 60 | applicationContext.sendNotification( 61 | parcel, apiParcel.currentStatus, apiParcel.history.first()) 62 | statusDao.update(ParcelStatus(parcel.id, apiParcel.currentStatus, lastChange)) 63 | } 64 | 65 | else -> Log.d("NotificationWorker", "Parcel has not had any updates yet.") 66 | } 67 | } 68 | } 69 | 70 | return Result.success() 71 | } 72 | } 73 | 74 | private const val WORK_NAME = "ParcelTrackerNotificationWorker" 75 | 76 | suspend fun Context.enqueueNotificationWorker() { 77 | val unmeteredOnly = this.dataStore.data.map { it[UNMETERED_ONLY] ?: false }.first() 78 | 79 | val constraints = 80 | Constraints.Builder() 81 | .setRequiredNetworkType( 82 | if (unmeteredOnly) NetworkType.UNMETERED else NetworkType.CONNECTED) 83 | .build() 84 | 85 | val request = 86 | PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES) 87 | .setConstraints(constraints) 88 | .build() 89 | 90 | WorkManager.getInstance(this) 91 | .enqueueUniquePeriodicWork( 92 | WORK_NAME, ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, request) 93 | } 94 | 95 | suspend fun Context.enqueueWorkerIfNotQueued() { 96 | val wm = WorkManager.getInstance(this) 97 | val infos = wm.getWorkInfosForUniqueWork(WORK_NAME).await() 98 | if (infos.isEmpty() || 99 | (infos.first().state != WorkInfo.State.ENQUEUED && 100 | infos.first().state != WorkInfo.State.RUNNING)) { 101 | this.enqueueNotificationWorker() 102 | } else { 103 | Log.d("NotificationWorker", "Already enqueued/running, not doing it again") 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /app/src/main/java/dev/itsvic/parceltracker/Notifications.kt: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | package dev.itsvic.parceltracker 3 | 4 | import android.Manifest 5 | import android.app.NotificationChannel 6 | import android.app.NotificationManager 7 | import android.app.PendingIntent 8 | import android.content.Context 9 | import android.content.Context.NOTIFICATION_SERVICE 10 | import android.content.Intent 11 | import android.content.pm.PackageManager 12 | import androidx.core.app.ActivityCompat 13 | import androidx.core.app.NotificationCompat 14 | import androidx.core.app.NotificationManagerCompat 15 | import dev.itsvic.parceltracker.api.ParcelHistoryItem 16 | import dev.itsvic.parceltracker.api.Status 17 | import dev.itsvic.parceltracker.db.Parcel 18 | 19 | const val CHANNEL_ID = "ParcelTrackerEvents" 20 | 21 | fun Context.sendNotification(parcel: Parcel, status: Status, event: ParcelHistoryItem) { 22 | val context = this 23 | val statusString = getString(status.nameResource) 24 | 25 | val intent = 26 | Intent(this, MainActivity::class.java).apply { 27 | putExtra("openParcel", parcel.id) 28 | flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP 29 | } 30 | val pendingIntent = 31 | PendingIntent.getActivity( 32 | this, 0, intent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) 33 | 34 | val builder = 35 | NotificationCompat.Builder(this, CHANNEL_ID) 36 | .setSmallIcon(R.drawable.package_2) 37 | .setContentTitle("${parcel.humanName}: $statusString") 38 | .setContentText(event.description) 39 | .setPriority(NotificationCompat.PRIORITY_DEFAULT) 40 | .setContentIntent(pendingIntent) 41 | .setAutoCancel(true) 42 | 43 | with(NotificationManagerCompat.from(this)) { 44 | if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != 45 | PackageManager.PERMISSION_GRANTED) { 46 | return 47 | } 48 | notify(parcel.id, builder.build()) 49 | } 50 | } 51 | 52 | fun Context.createNotificationChannel() { 53 | val name = getString(R.string.channel_name) 54 | val description = getString(R.string.channel_description) 55 | val importance = NotificationManager.IMPORTANCE_HIGH 56 | val channel = NotificationChannel(CHANNEL_ID, name, importance) 57 | channel.description = description 58 | val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager 59 | notificationManager.createNotificationChannel(channel) 60 | } 61 | -------------------------------------------------------------------------------- /app/src/main/java/dev/itsvic/parceltracker/ParcelApplication.kt: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | package dev.itsvic.parceltracker 3 | 4 | import android.app.Application 5 | import androidx.room.Room 6 | import dev.itsvic.parceltracker.db.AppDatabase 7 | import kotlinx.coroutines.MainScope 8 | import kotlinx.coroutines.launch 9 | 10 | class ParcelApplication : Application() { 11 | override fun onCreate() { 12 | super.onCreate() 13 | db = Room.databaseBuilder(applicationContext, AppDatabase::class.java, "parcel-tracker").build() 14 | 15 | applicationContext.createNotificationChannel() 16 | MainScope().launch { applicationContext.enqueueWorkerIfNotQueued() } 17 | } 18 | 19 | companion object { 20 | lateinit var db: AppDatabase 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/src/main/java/dev/itsvic/parceltracker/Settings.kt: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | package dev.itsvic.parceltracker 3 | 4 | import android.content.Context 5 | import androidx.datastore.core.DataStore 6 | import androidx.datastore.preferences.core.Preferences 7 | import androidx.datastore.preferences.core.booleanPreferencesKey 8 | import androidx.datastore.preferences.core.stringPreferencesKey 9 | import androidx.datastore.preferences.preferencesDataStore 10 | 11 | val Context.dataStore: DataStore by preferencesDataStore(name = "settings") 12 | val DEMO_MODE = booleanPreferencesKey("demoMode") 13 | val UNMETERED_ONLY = booleanPreferencesKey("unmeteredOnly") 14 | 15 | // API key settings 16 | val DHL_API_KEY = stringPreferencesKey("dhlApiKey") 17 | -------------------------------------------------------------------------------- /app/src/main/java/dev/itsvic/parceltracker/api/BelpostDeliveryService.kt: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | package dev.itsvic.parceltracker.api 3 | 4 | import com.squareup.moshi.JsonClass 5 | import dev.itsvic.parceltracker.R 6 | import java.time.Instant 7 | import java.time.LocalDateTime 8 | import java.util.TimeZone 9 | import retrofit2.Retrofit 10 | import retrofit2.http.Body 11 | import retrofit2.http.POST 12 | 13 | object BelpostDeliveryService : DeliveryService { 14 | override val nameResource: Int = R.string.service_belpost 15 | override val acceptsPostCode: Boolean = false 16 | override val requiresPostCode: Boolean = false 17 | 18 | override suspend fun getParcel(trackingId: String, postalCode: String?): Parcel { 19 | val req = ParcelRequest(trackingId) 20 | val resp = 21 | try { 22 | service.getParcel(req) 23 | } catch (_: Exception) { 24 | throw ParcelNonExistentException() 25 | } 26 | 27 | if (resp.data.isEmpty()) { 28 | throw ParcelNonExistentException() 29 | } 30 | 31 | val history = 32 | resp.data[0].steps.map { item -> 33 | ParcelHistoryItem( 34 | item.event, 35 | LocalDateTime.ofInstant( 36 | Instant.ofEpochSecond(item.timestamp.toLong()), TimeZone.getDefault().toZoneId()), 37 | item.place) 38 | } 39 | 40 | /* 41 | 1 - accepted from sender (outside country) 42 | 3 - arrived to international warehouse (customs?, outside country) 43 | 4 - accepted from sender 44 | 6 - processing 45 | 8 - sent (outside country) 46 | 15 - sent 47 | 21 - picked up 48 | 30 - arrived to international warehouse (customs?, inside country) 49 | 31 - ready for customs 50 | 32 - arrived to warehouse (related to customs) 51 | 35 - directed (customs) 52 | 38 - end of customs 53 | 70 - arrived to warehouse 54 | */ 55 | val status = 56 | if (resp.data[0].steps.isEmpty()) Status.Preadvice 57 | else 58 | when (resp.data[0].steps.first().code.toInt()) { 59 | 1 -> Status.Preadvice 60 | 3 -> Status.Customs 61 | 4 -> Status.Preadvice 62 | 6 -> Status.InWarehouse 63 | 8 -> Status.InTransit 64 | 15 -> Status.InTransit 65 | 21 -> Status.OutForDelivery 66 | 30 -> Status.Customs 67 | 31 -> Status.Customs 68 | 32 -> Status.InWarehouse 69 | 35 -> Status.InTransit 70 | 38 -> Status.Customs 71 | 70 -> Status.InWarehouse 72 | else -> logUnknownStatus("Belpost", resp.data[0].steps.last().code.toString()) 73 | } 74 | 75 | return Parcel(resp.data[0].number, history, status) 76 | } 77 | 78 | private val retrofit = 79 | Retrofit.Builder() 80 | .baseUrl("https://api.belpost.by/api/v1/") 81 | .client(api_client) 82 | .addConverterFactory(api_factory) 83 | .build() 84 | 85 | private val service = retrofit.create(API::class.java) 86 | 87 | private interface API { 88 | @POST("tracking") suspend fun getParcel(@Body data: ParcelRequest): ParcelResponse 89 | } 90 | 91 | @JsonClass(generateAdapter = true) 92 | internal data class ParcelRequest( 93 | val number: String, 94 | ) 95 | 96 | @JsonClass(generateAdapter = true) 97 | internal data class ParcelResponse( 98 | // not sure why this is a list when it always has one item 99 | val data: List, 100 | val uuid: String, 101 | val barcode: String, 102 | ) 103 | 104 | @JsonClass(generateAdapter = true) 105 | internal data class MailInfo( 106 | val number: String, 107 | val steps: List, 108 | ) 109 | 110 | @JsonClass(generateAdapter = true) 111 | internal data class Event( 112 | val code: Double, 113 | val event: String, 114 | val place: String, 115 | val timestamp: Double, 116 | ) 117 | } 118 | -------------------------------------------------------------------------------- /app/src/main/java/dev/itsvic/parceltracker/api/CainiaoDeliveryService.kt: -------------------------------------------------------------------------------- 1 | package dev.itsvic.parceltracker.api 2 | 3 | import com.squareup.moshi.JsonClass 4 | import dev.itsvic.parceltracker.R 5 | import java.time.Instant 6 | import java.time.LocalDateTime 7 | import java.time.ZoneId 8 | import java.util.Locale 9 | import retrofit2.Retrofit 10 | import retrofit2.converter.moshi.MoshiConverterFactory 11 | import retrofit2.http.GET 12 | import retrofit2.http.Query 13 | 14 | object CainiaoDeliveryService : DeliveryService { 15 | override val nameResource: Int = R.string.service_cainiao 16 | override val acceptsPostCode: Boolean = false 17 | override val requiresPostCode: Boolean = false 18 | 19 | override suspend fun getParcel(trackingID: String, postalCode: String?): Parcel { 20 | var userLocale = Locale.getDefault() 21 | var language = userLocale.language 22 | val country = userLocale.country 23 | 24 | val parcelResp = service.getParcel(trackingID, "$language-$country", "$language-$country") 25 | 26 | if (!parcelResp.success || 27 | parcelResp.module.isEmpty() || 28 | parcelResp.module.first().detailList.isEmpty()) { 29 | throw ParcelNonExistentException() 30 | } 31 | 32 | val parcel = parcelResp.module.first() 33 | 34 | val history = 35 | parcel.detailList.map { 36 | ParcelHistoryItem( 37 | it.standerdDesc, 38 | // Sometimes new parcels have no timezone info, so default to origin country (china) 39 | LocalDateTime.ofInstant( 40 | Instant.ofEpochMilli(it.time), 41 | ZoneId.of(if (it.timeZone.isBlank()) "GMT+8" else it.timeZone)), 42 | "") 43 | } 44 | 45 | // Based on the few tracking numbers I have, there may be more 46 | val status = 47 | when (parcel.detailList.first().actionCode) { 48 | "GWMS_ACCEPT" -> Status.Preadvice 49 | "GWMS_PACKAGE" -> Status.AwaitingPickup 50 | "GWMS_OUTBOUND" -> Status.InTransit 51 | "PU_PICKUP_SUCCESS" -> Status.PickedUp 52 | "CW_OUTBOUND" -> Status.InTransit 53 | "SC_INBOUND_SUCCESS" -> Status.InWarehouse 54 | "SC_OUTBOUND_SUCCESS" -> Status.InTransit 55 | "LH_HO_IN_SUCCESS" -> Status.InWarehouse 56 | "LH_HO_AIRLINE" -> Status.InTransit 57 | "LH_DEPART" -> Status.InTransit 58 | "LH_ARRIVE" -> Status.InWarehouse 59 | "CC_EX_START" -> Status.Customs 60 | "CC_EX_SUCCESS" -> Status.CustomsSuccess 61 | "CC_HO_IN_SUCCESS" -> Status.Customs 62 | "CC_HO_OUT_SUCCESS" -> Status.InTransit 63 | "CC_IM_START" -> Status.Customs 64 | "CC_IM_SUCCESS" -> Status.CustomsSuccess 65 | "TD_TRANSWH_OUTBOUND" -> Status.InTransit 66 | "GTMS_ACCEPT" -> Status.InTransit 67 | "GTMS_DO_ARRIVE" -> Status.InWarehouse 68 | "GTMS_DO_DEPART" -> Status.OutForDelivery 69 | "GTMS_STATION_OUT" -> Status.InTransit 70 | "GTMS_SIGNED" -> Status.Delivered 71 | "GTMS_DEL_FAILURE" -> Status.DeliveryFailure 72 | else -> logUnknownStatus("Cainiao", parcel.detailList.first().actionCode) 73 | } 74 | 75 | return Parcel(trackingID, history, status) 76 | } 77 | 78 | private val retrofit = 79 | Retrofit.Builder() 80 | .baseUrl("https://global.cainiao.com/global/") 81 | .client(api_client) 82 | .addConverterFactory(MoshiConverterFactory.create(api_moshi)) 83 | .build() 84 | 85 | private val service = retrofit.create(API::class.java) 86 | 87 | private interface API { 88 | @GET("detail.json") 89 | suspend fun getParcel( 90 | @Query("mailNos") trackingID: String, 91 | @Query("lang") lang: String, 92 | @Query("language") language: String 93 | ): ParcelResponse 94 | } 95 | 96 | @JsonClass(generateAdapter = true) 97 | internal data class ParcelResponse( 98 | val module: List, 99 | val success: Boolean, 100 | ) 101 | 102 | @JsonClass(generateAdapter = true) 103 | internal data class CainiaoParcel( 104 | val detailList: List, 105 | ) 106 | 107 | @JsonClass(generateAdapter = true) 108 | internal data class Event( 109 | val actionCode: String, 110 | val standerdDesc: String, 111 | val time: Long, 112 | val timeZone: String, 113 | ) 114 | } 115 | -------------------------------------------------------------------------------- /app/src/main/java/dev/itsvic/parceltracker/api/CommonFormats.kt: -------------------------------------------------------------------------------- 1 | package dev.itsvic.parceltracker.api 2 | 3 | val emsFormat = """^\w{2}\d{9}\w{2}$""".toRegex() 4 | val digits11Format = """^\d{11}$""".toRegex() 5 | val digits12Format = """^\d{12}$""".toRegex() 6 | val digits18Format = """^\d{18}$""".toRegex() 7 | 8 | fun Regex.accepts(string: String): Boolean { 9 | return this.matchEntire(string) != null 10 | } 11 | -------------------------------------------------------------------------------- /app/src/main/java/dev/itsvic/parceltracker/api/DpdGerDeliveryService.kt: -------------------------------------------------------------------------------- 1 | package dev.itsvic.parceltracker.api 2 | 3 | import com.squareup.moshi.JsonClass 4 | import dev.itsvic.parceltracker.R 5 | import java.time.LocalDateTime 6 | import java.time.format.DateTimeFormatter 7 | import retrofit2.HttpException 8 | import retrofit2.Retrofit 9 | import retrofit2.http.GET 10 | import retrofit2.http.Path 11 | 12 | object DpdGerDeliveryService : DeliveryService { 13 | override val nameResource: Int 14 | get() = R.string.service_dpd_ger 15 | 16 | override val acceptsPostCode: Boolean 17 | get() = false 18 | 19 | override val requiresPostCode: Boolean 20 | get() = false 21 | 22 | override suspend fun getParcel(trackingId: String, postCode: String?): Parcel { 23 | val resp = 24 | try { 25 | service.getShipment(trackingId) 26 | } catch (_: HttpException) { 27 | throw ParcelNonExistentException() 28 | } 29 | 30 | val status = 31 | when (val current = 32 | resp.parcellifecycleResponse.parcelLifeCycleData.statusInfo 33 | .find { it.isCurrentStatus == true } 34 | ?.status) { 35 | "ACCEPTED" -> Status.Preadvice 36 | "ON_THE_ROAD" -> Status.InTransit 37 | "HANDOVER_CONSIGNOR_TO_PARCELSHOP", 38 | "AT_DELIVERY_DEPOT" -> Status.InWarehouse 39 | "OUT_FOR_DELIVERY" -> Status.OutForDelivery 40 | "DELIVERED" -> Status.Delivered 41 | else -> logUnknownStatus("DPD Germany", current!!) 42 | } 43 | 44 | val h = 45 | resp.parcellifecycleResponse.parcelLifeCycleData.statusInfo.filter { 46 | it.statusHasBeenReached 47 | } 48 | 49 | val formatter = DateTimeFormatter.ofPattern("dd.MM.yyyy, HH:mm") 50 | val history = 51 | h.map { ParcelHistoryItem(it.label, LocalDateTime.parse(it.date, formatter), it.location) } 52 | 53 | return Parcel(trackingId, history, status) 54 | } 55 | 56 | private val retrofit = 57 | Retrofit.Builder() 58 | .baseUrl("https://tracking.dpd.de/rest/plc/en_DE/") 59 | .client(api_client) 60 | .addConverterFactory(api_factory) 61 | .build() 62 | 63 | private val service = retrofit.create(API::class.java) 64 | 65 | private interface API { 66 | @GET("{id}") suspend fun getShipment(@Path("id") trackingId: String): ParcelData 67 | } 68 | 69 | @JsonClass(generateAdapter = true) 70 | internal data class ParcelData(val parcellifecycleResponse: ParcelLifeCycleResponse) 71 | 72 | @JsonClass(generateAdapter = true) 73 | internal data class ParcelLifeCycleResponse(val parcelLifeCycleData: ParcelLifeCycleData) 74 | 75 | @JsonClass(generateAdapter = true) 76 | internal data class ParcelLifeCycleData( 77 | val statusInfo: List, 78 | ) 79 | 80 | @JsonClass(generateAdapter = true) 81 | internal data class StatusInfo( 82 | val status: String, 83 | val label: String, 84 | val statusHasBeenReached: Boolean, 85 | val isCurrentStatus: Boolean, 86 | val location: String = "Unknown", 87 | val date: String? 88 | ) 89 | } 90 | -------------------------------------------------------------------------------- /app/src/main/java/dev/itsvic/parceltracker/api/DpdUkDeliveryService.kt: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | package dev.itsvic.parceltracker.api 3 | 4 | import android.util.Log 5 | import com.squareup.moshi.JsonClass 6 | import dev.itsvic.parceltracker.R 7 | import java.time.LocalDateTime 8 | import java.time.format.DateTimeFormatter 9 | import okhttp3.ResponseBody 10 | import retrofit2.Response 11 | import retrofit2.Retrofit 12 | import retrofit2.http.GET 13 | import retrofit2.http.Path 14 | import retrofit2.http.Query 15 | 16 | object DpdUkDeliveryService : DeliveryService { 17 | override val nameResource: Int = R.string.service_dpd_uk 18 | override val acceptsPostCode: Boolean = true 19 | override val requiresPostCode: Boolean = false 20 | 21 | override suspend fun getParcel(trackingId: String, postalCode: String?): Parcel { 22 | val urlResp = service.getParcelURL(trackingId, postalCode) 23 | 24 | if (urlResp.code() == 404) throw ParcelNonExistentException() 25 | Log.d("DpdUk", "$urlResp") 26 | val id = urlResp.raw().request.url.pathSegments.last() 27 | Log.d("DpdUk", "id=$id") 28 | 29 | val events = service.getParcelEvents(id) 30 | 31 | val history = 32 | events.data.map { 33 | ParcelHistoryItem( 34 | it.eventText, 35 | LocalDateTime.parse(it.eventDate.replace(' ', 'T'), DateTimeFormatter.ISO_DATE_TIME), 36 | it.eventLocation, 37 | ) 38 | } 39 | 40 | val status = 41 | when (val statusCode = events.data.first().eventCode) { 42 | "000" -> Status.Preadvice 43 | "009" -> Status.InTransit 44 | "004" -> Status.InWarehouse 45 | "015" -> Status.OutForDelivery 46 | "001" -> Status.Delivered 47 | else -> logUnknownStatus("DPD UK", statusCode) 48 | } 49 | 50 | return Parcel(trackingId, history, status) 51 | } 52 | 53 | private val retrofit = 54 | Retrofit.Builder() 55 | .baseUrl("https://apis.track.dpd.co.uk/v1/") 56 | .client(api_client) 57 | .addConverterFactory(api_factory) 58 | .build() 59 | 60 | private val service = retrofit.create(API::class.java) 61 | 62 | private interface API { 63 | @GET("track") 64 | suspend fun getParcelURL( 65 | @Query("parcel") trackingId: String, 66 | @Query("postcode") postcode: String? 67 | ): Response 68 | 69 | // currently unused by us 70 | // @GET("parcels/{id}") 71 | // suspend fun getParcelInfo(@Path("id") id: String) 72 | 73 | @GET("parcels/{id}/parcelevents") 74 | suspend fun getParcelEvents(@Path("id") id: String): ParcelEventsResponse 75 | } 76 | 77 | @JsonClass(generateAdapter = true) 78 | internal data class ParcelEventsResponse(val data: List) 79 | 80 | @JsonClass(generateAdapter = true) 81 | internal data class Event( 82 | val eventCode: String, 83 | val eventDate: String, 84 | val eventText: String, 85 | val eventLocation: String, 86 | ) 87 | } 88 | -------------------------------------------------------------------------------- /app/src/main/java/dev/itsvic/parceltracker/api/EKartDeliveryService.kt: -------------------------------------------------------------------------------- 1 | package dev.itsvic.parceltracker.api 2 | 3 | import com.squareup.moshi.JsonClass 4 | import dev.itsvic.parceltracker.R 5 | import java.time.format.DateTimeFormatter 6 | import java.time.format.FormatStyle 7 | import retrofit2.HttpException 8 | import retrofit2.Retrofit 9 | import retrofit2.http.Body 10 | import retrofit2.http.POST 11 | 12 | object EKartDeliveryService : DeliveryService { 13 | override val nameResource: Int = R.string.service_ekart 14 | override val acceptsPostCode: Boolean = false 15 | override val requiresPostCode: Boolean = false 16 | 17 | override suspend fun getParcel(trackingId: String, postalCode: String?): Parcel { 18 | val resp = 19 | try { 20 | service.getTrackingDetails(GetTrackingDetailsRequest(trackingId)) 21 | } catch (_: HttpException) { 22 | throw ParcelNonExistentException() 23 | } 24 | 25 | // these are the same checks that the official tracking page does. roughly. 26 | val status = 27 | when { 28 | resp.shipmentTrackingDetails.last().statusDetails == "Delivered" -> Status.Delivered 29 | resp.shipmentTrackingDetails.last().statusDetails == "Out For Delivery" -> 30 | Status.OutForDelivery 31 | resp.reachedNearestHub == true -> Status.InWarehouse 32 | // assume that, if it hasn't reachedNearestHub yet, and it's moving, it's in transit 33 | // this is a check that's not done originally but we do it here for clarity's sake 34 | resp.faShipment == true && resp.shipmentTrackingDetails.size != 1 -> Status.InTransit 35 | resp.faShipment == true && resp.shipmentTrackingDetails.size == 1 -> Status.Preadvice 36 | else -> logUnknownStatus("eKart", resp.shipmentTrackingDetails.last().statusDetails) 37 | } 38 | 39 | val history = 40 | resp.shipmentTrackingDetails.reversed().map { 41 | ParcelHistoryItem(it.statusDetails, localDateFromMilli(it.date), it.city) 42 | } 43 | 44 | val properties = 45 | mapOf( 46 | (if (status == Status.Delivered) R.string.property_delivery_time 47 | else R.string.property_eta) to 48 | localDateFromMilli(resp.expectedDeliveryDate) 49 | .format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT))) 50 | 51 | return Parcel(trackingId, history, status, properties) 52 | } 53 | 54 | private val retrofit = 55 | Retrofit.Builder() 56 | .baseUrl("https://ekartlogistics.com/ws/") 57 | .client(api_client) 58 | .addConverterFactory(api_factory) 59 | .build() 60 | 61 | private val service = retrofit.create(API::class.java) 62 | 63 | private interface API { 64 | @POST("getTrackingDetails") 65 | suspend fun getTrackingDetails(@Body body: GetTrackingDetailsRequest): TrackingDetails 66 | } 67 | 68 | @JsonClass(generateAdapter = true) 69 | internal data class GetTrackingDetailsRequest( 70 | val trackingId: String, 71 | ) 72 | 73 | @JsonClass(generateAdapter = true) 74 | internal data class TrackingDetails( 75 | val expectedDeliveryDate: Long, 76 | // i dont trust that these exist when they're false 77 | val faShipment: Boolean?, 78 | val reachedNearestHub: Boolean?, 79 | val shipmentTrackingDetails: List, 80 | ) 81 | 82 | @JsonClass(generateAdapter = true) 83 | internal data class TrackingEvent( 84 | val date: Long, 85 | val city: String, 86 | val statusDetails: String, 87 | ) 88 | } 89 | -------------------------------------------------------------------------------- /app/src/main/java/dev/itsvic/parceltracker/api/EvriDeliveryService.kt: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | package dev.itsvic.parceltracker.api 3 | 4 | import com.squareup.moshi.JsonClass 5 | import dev.itsvic.parceltracker.R 6 | import java.time.Instant 7 | import java.time.ZoneId 8 | import retrofit2.HttpException 9 | import retrofit2.Retrofit 10 | import retrofit2.converter.moshi.MoshiConverterFactory 11 | import retrofit2.http.GET 12 | import retrofit2.http.Headers 13 | import retrofit2.http.Path 14 | import retrofit2.http.Query 15 | 16 | object EvriDeliveryService : DeliveryService { 17 | override val nameResource: Int = R.string.service_evri 18 | override val acceptsPostCode: Boolean = false 19 | override val requiresPostCode: Boolean = false 20 | 21 | override suspend fun getParcel(trackingId: String, postalCode: String?): Parcel { 22 | val urnResp = 23 | try { 24 | val resp = service.getParcelURN(trackingId) 25 | if (resp.parcelIdentifiers.isEmpty()) throw ParcelNonExistentException() 26 | 27 | resp 28 | } catch (_: HttpException) { 29 | throw ParcelNonExistentException() 30 | } 31 | 32 | val urn = urnResp.parcelIdentifiers.first().urn 33 | val parcelResp = service.getParcel(urn) 34 | val parcel = parcelResp.results.first() 35 | 36 | val history = 37 | parcel.trackingEvents.map { 38 | ParcelHistoryItem( 39 | it.trackingPoint.description, 40 | Instant.parse(it.dateTime).atZone(ZoneId.systemDefault()).toLocalDateTime(), 41 | "") 42 | } 43 | 44 | val status = 45 | when (parcel.trackingEvents.first().trackingStage.trackingStageCode) { 46 | "1" -> Status.Preadvice 47 | "2" -> Status.InWarehouse 48 | "3" -> Status.InTransit 49 | "4_COURIER" -> Status.OutForDelivery 50 | "5_COURIER" -> Status.Delivered 51 | else -> 52 | logUnknownStatus( 53 | "Evri", parcel.trackingEvents.first().trackingStage.trackingStageCode) 54 | } 55 | 56 | return Parcel(trackingId, history, status) 57 | } 58 | 59 | private val retrofit = 60 | Retrofit.Builder() 61 | .baseUrl("https://api.hermesworld.co.uk/enterprise-tracking-api/v1/") 62 | .client(api_client) 63 | .addConverterFactory(MoshiConverterFactory.create(api_moshi)) 64 | .build() 65 | 66 | private val service = retrofit.create(API::class.java) 67 | 68 | // TODO: there may be more info with a postcode, but i can't really test that 69 | private interface API { 70 | @Headers("Apikey: 0DExZiK9in2ihGce7cDPrnpQ4s4nIpWG") 71 | @GET("parcels/reference/{id}") 72 | suspend fun getParcelURN(@Path("id") trackingId: String): ParcelReferenceResponse 73 | 74 | @Headers("Apikey: Vi8HZURvXHANfpiFDGta6bJclafLJcAY") 75 | @GET("parcels") 76 | suspend fun getParcel(@Query("uniqueIds") urn: String): ParcelResponse 77 | } 78 | 79 | @JsonClass(generateAdapter = true) 80 | internal data class ParcelReferenceResponse( 81 | val parcelIdentifiers: List, 82 | ) 83 | 84 | @JsonClass(generateAdapter = true) 85 | internal data class ParcelIdentifier( 86 | val urn: String, 87 | ) 88 | 89 | @JsonClass(generateAdapter = true) 90 | internal data class ParcelResponse( 91 | val failures: List, 92 | val results: List, 93 | ) 94 | 95 | @JsonClass(generateAdapter = true) 96 | internal data class FailedQueries( 97 | val uniqueId: String, 98 | ) 99 | 100 | @JsonClass(generateAdapter = true) 101 | internal data class EvriParcel( 102 | val trackingEvents: List, 103 | ) 104 | 105 | @JsonClass(generateAdapter = true) 106 | internal data class Event( 107 | val trackingPoint: TrackingPoint, 108 | val trackingStage: TrackingStage, 109 | val dateTime: String, 110 | ) 111 | 112 | @JsonClass(generateAdapter = true) 113 | internal data class TrackingPoint( 114 | val description: String, 115 | ) 116 | 117 | @JsonClass(generateAdapter = true) 118 | internal data class TrackingStage( 119 | val trackingStageCode: String, 120 | ) 121 | } 122 | -------------------------------------------------------------------------------- /app/src/main/java/dev/itsvic/parceltracker/api/ExampleDeliveryService.kt: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | package dev.itsvic.parceltracker.api 3 | 4 | import dev.itsvic.parceltracker.R 5 | import java.time.LocalDateTime 6 | import kotlin.random.Random 7 | 8 | // "Example Post" backend. 9 | // This is used only by Demo Mode. This is not a real API backend. 10 | 11 | object ExampleDeliveryService : DeliveryService { 12 | override val nameResource: Int = R.string.service_example 13 | override val acceptsPostCode: Boolean = true 14 | override val requiresPostCode: Boolean = false 15 | 16 | override suspend fun getParcel(trackingId: String, postalCode: String?): Parcel { 17 | if (!exampleParcels.containsKey(trackingId)) throw ParcelNonExistentException() 18 | return exampleParcels[trackingId]!! 19 | } 20 | 21 | /** Defines a parcel history item $hoursBack hours +/- 30 minutes behind the current time. */ 22 | private fun historyItem( 23 | status: String, 24 | hoursBack: Number, 25 | ): ParcelHistoryItem { 26 | val baseDate = LocalDateTime.now().minusHours(hoursBack.toLong()) 27 | val randomizedDate = baseDate.plusSeconds(Random.nextLong(-1800, 1800)) 28 | return ParcelHistoryItem(status, randomizedDate, "Mars") 29 | } 30 | 31 | private val exampleParcels = 32 | mapOf( 33 | "2503894188" to 34 | Parcel( 35 | "2503894188", 36 | listOf( 37 | historyItem("Delivered", 1), 38 | historyItem("Out for delivery", 5), 39 | historyItem("Arrived at local warehouse", 24), 40 | historyItem("In transit", 48), 41 | historyItem("Label created", 96), 42 | ), 43 | Status.Delivered), 44 | "7301626157" to 45 | Parcel( 46 | "7301626157", 47 | listOf( 48 | historyItem("Awaiting pickup at parcel locker", 2), 49 | historyItem("Out for delivery", 5), 50 | historyItem("Arrived at local warehouse", 24), 51 | historyItem("In transit", 48), 52 | historyItem("Label created", 96), 53 | ), 54 | Status.AwaitingPickup), 55 | "6171197286" to 56 | Parcel("6171197286", listOf(historyItem("Label created", 2)), Status.Preadvice), 57 | ) 58 | } 59 | -------------------------------------------------------------------------------- /app/src/main/java/dev/itsvic/parceltracker/api/HermesDeliveryService.kt: -------------------------------------------------------------------------------- 1 | package dev.itsvic.parceltracker.api 2 | 3 | import android.content.Context 4 | import com.squareup.moshi.JsonClass 5 | import dev.itsvic.parceltracker.R 6 | import java.time.LocalDateTime 7 | import java.time.format.DateTimeFormatter 8 | import retrofit2.HttpException 9 | import retrofit2.Retrofit 10 | import retrofit2.http.GET 11 | import retrofit2.http.Path 12 | 13 | object HermesDeliveryService : DeliveryService { 14 | override val nameResource: Int 15 | get() = R.string.service_hermes 16 | 17 | override val acceptsPostCode: Boolean 18 | get() = false 19 | 20 | override val requiresPostCode: Boolean 21 | get() = false 22 | 23 | override suspend fun getParcel( 24 | context: Context, 25 | trackingId: String, 26 | postalCode: String? 27 | ): Parcel { 28 | 29 | val resp = 30 | try { 31 | service.getShipments(trackingId) 32 | } catch (_: HttpException) { 33 | throw ParcelNonExistentException() 34 | } 35 | 36 | val status = 37 | when (resp.status.parcelStatus) { 38 | "ZUGESTELLT", 39 | "RETOURE_AUSGELIEFERT_BEIM_ATG" -> Status.Delivered 40 | "ZUSTELLTOUR" -> Status.OutForDelivery 41 | "AVISE" -> Status.Preadvice 42 | "SENDUNG_VON_HERMES_UEBERNOMMEN", 43 | "AM_PKS_ABGEGEBEN" -> Status.InWarehouse 44 | "UMSCHLAG_INLAND", 45 | "SENDUNG_IN_ZIELREGION_ANGEKOMMEN" -> Status.InTransit 46 | else -> logUnknownStatus("Hermes", resp.status.parcelStatus) 47 | } 48 | 49 | val statusReached = resp.parcelHistory.filter { it.timestamp != null } 50 | 51 | val history = 52 | statusReached.map { 53 | ParcelHistoryItem( 54 | it.statusHistoryText!!, 55 | LocalDateTime.parse(it.timestamp, DateTimeFormatter.ISO_DATE_TIME), 56 | "") 57 | } 58 | 59 | return Parcel(trackingId, history, status) 60 | } 61 | 62 | private val retrofit = 63 | Retrofit.Builder() 64 | .baseUrl("https://api.my-deliveries.de/tnt/parcelservice/") 65 | .client(api_client) 66 | .addConverterFactory(api_factory) 67 | .build() 68 | 69 | private val service = retrofit.create(API::class.java) 70 | 71 | private interface API { 72 | @GET("parceldetails/{id}") 73 | suspend fun getShipments(@Path("id") trackingId: String): HermesParcelData 74 | } 75 | 76 | @JsonClass(generateAdapter = true) 77 | internal data class HermesParcelData( 78 | val barcode: String, 79 | val receipt: String?, 80 | val order: String?, 81 | val status: HermesParcelStatus, 82 | val parcelHistory: List 83 | ) 84 | 85 | @JsonClass(generateAdapter = true) 86 | internal data class HermesParcelStatus(val parcelStatus: String, val timestamp: String) 87 | 88 | @JsonClass(generateAdapter = true) 89 | internal data class HermesParcelHistory( 90 | val timestamp: String?, 91 | val status: String, 92 | val statusHistoryText: String? 93 | ) 94 | } 95 | -------------------------------------------------------------------------------- /app/src/main/java/dev/itsvic/parceltracker/api/MagyarPostaDeliveryService.kt: -------------------------------------------------------------------------------- 1 | package dev.itsvic.parceltracker.api 2 | 3 | import android.os.LocaleList 4 | import com.squareup.moshi.JsonClass 5 | import dev.itsvic.parceltracker.R 6 | import java.time.Instant 7 | import java.time.LocalDateTime 8 | import java.util.TimeZone 9 | import retrofit2.HttpException 10 | import retrofit2.Retrofit 11 | import retrofit2.http.GET 12 | import retrofit2.http.Query 13 | 14 | // magyar fosta 15 | object MagyarPostaDeliveryService : DeliveryService { 16 | override val nameResource: Int = R.string.service_hungarian_post 17 | override val acceptsPostCode: Boolean = false 18 | override val requiresPostCode: Boolean = false 19 | 20 | override fun acceptsFormat(trackingId: String): Boolean { 21 | val someWeirdFormat = """^\w{3}\d{2}\w{7}\d{8}$""".toRegex() 22 | val govFormat = """^RL\d{14}$""".toRegex() 23 | val parcelLockerFormat = """^PNTM\d{22}$""".toRegex() 24 | return someWeirdFormat.accepts(trackingId) || 25 | emsFormat.accepts(trackingId) || 26 | govFormat.accepts(trackingId) || 27 | parcelLockerFormat.accepts(trackingId) 28 | } 29 | 30 | override suspend fun getParcel(trackingId: String, postalCode: String?): Parcel { 31 | val locale = LocaleList.getDefault().get(0).language 32 | val resp = 33 | try { 34 | service.getTrackingInfo(trackingId, language = if (locale == "hu") 1 else 2) 35 | } catch (_: HttpException) { 36 | throw ParcelNonExistentException() 37 | } 38 | 39 | val events = resp[trackingId] 40 | if (events == null) { 41 | throw ParcelNonExistentException() 42 | } 43 | 44 | val history = 45 | events.map { 46 | ParcelHistoryItem( 47 | it.tranzakcioTipusLeiras, 48 | LocalDateTime.ofInstant( 49 | Instant.ofEpochMilli(it.time), TimeZone.getDefault().toZoneId()), 50 | it.postaNev.replace('|', '\n')) 51 | } 52 | 53 | val category = events.first().tranzakcioKategoriaKod 54 | val detailedType = events.first().tranzakcioAzon 55 | 56 | val status = 57 | when (category) { 58 | "1" -> Status.Preadvice 59 | 60 | "2" -> 61 | when (detailedType) { 62 | "AVATS" -> Status.AwaitingPickup 63 | "30" -> Status.InWarehouse 64 | "31", 65 | "38", 66 | "NAV_KIADHATO_H7", 67 | "UERT18_ONLINE", 68 | "SIKERES_ONLINE_FIZ" -> Status.Customs 69 | else -> Status.InTransit 70 | } 71 | 72 | "3" -> 73 | when (detailedType) { 74 | "KLUVK" -> Status.DeliveryFailure 75 | else -> Status.InTransit 76 | } 77 | 78 | "4" -> 79 | when (detailedType) { 80 | "KIJKO" -> Status.OutForDelivery 81 | else -> Status.InTransit 82 | } 83 | 84 | "5" -> Status.Delivered 85 | else -> logUnknownStatus("Magyar Posta", "$category, $detailedType") 86 | } 87 | 88 | return Parcel(trackingId, history, status) 89 | } 90 | 91 | private val retrofit = 92 | Retrofit.Builder() 93 | .baseUrl("https://posta.hu/szolgaltatasok/PostaWeb/") 94 | .client(api_client) 95 | .addConverterFactory(api_factory) 96 | .build() 97 | 98 | private val service = retrofit.create(API::class.java) 99 | 100 | private interface API { 101 | @GET("TrackingInfo") 102 | suspend fun getTrackingInfo( 103 | @Query("ragszam") trackingId: String, 104 | @Query("language") language: Int = 2, 105 | @Query("registered") registered: Boolean = false, 106 | ): Map> 107 | } 108 | 109 | @JsonClass(generateAdapter = true) 110 | internal data class TrackingEvent( 111 | val time: Long, 112 | val postaNev: String, // post office 113 | val tranzakcioKategoriaKod: String, // category code 114 | val tranzakcioTipusLeiras: String, // type description 115 | val tranzakcioAzon: String, // transaction ID, i think it's meant to be transaction type tho 116 | ) 117 | } 118 | -------------------------------------------------------------------------------- /app/src/main/java/dev/itsvic/parceltracker/api/NovaPostDeliveryService.kt: -------------------------------------------------------------------------------- 1 | package dev.itsvic.parceltracker.api 2 | 3 | import com.squareup.moshi.JsonClass 4 | import dev.itsvic.parceltracker.R 5 | import java.time.ZoneId 6 | import java.time.ZonedDateTime 7 | import retrofit2.HttpException 8 | import retrofit2.Retrofit 9 | import retrofit2.http.GET 10 | import retrofit2.http.Path 11 | 12 | object NovaPostDeliveryService : DeliveryService { 13 | override val nameResource: Int = R.string.service_nova_poshta 14 | override val acceptsPostCode: Boolean = false 15 | override val requiresPostCode: Boolean = false 16 | 17 | override fun acceptsFormat(trackingId: String): Boolean { 18 | val regex = """^\d{14}$""".toRegex() 19 | return regex.accepts(trackingId) 20 | } 21 | 22 | override suspend fun getParcel(trackingId: String, postalCode: String?): Parcel { 23 | val resp = 24 | try { 25 | service.getParcel(trackingId) 26 | } catch (_: HttpException) { 27 | throw ParcelNonExistentException() 28 | } 29 | 30 | val history = 31 | resp.tracking.reversed().map { 32 | ParcelHistoryItem( 33 | it.event_name, 34 | ZonedDateTime.parse(it.date) 35 | .withZoneSameInstant(ZoneId.systemDefault()) 36 | .toLocalDateTime(), 37 | it.settlement_name) 38 | } 39 | 40 | // very crude guesswork. Nova Post does not make this easy at all lol 41 | val status = 42 | when (resp.tracking.last().code) { 43 | "1" -> Status.Preadvice 44 | "4", 45 | "6", 46 | "5" -> Status.InTransit 47 | "7", 48 | "8", 49 | "9" -> Status.Delivered 50 | else -> logUnknownStatus("Nova Post", resp.tracking.last().code) 51 | } 52 | 53 | return Parcel(resp.number, history, status) 54 | } 55 | 56 | private val retrofit = 57 | Retrofit.Builder() 58 | .baseUrl("https://api.novapost.com/site/v.1.0/") 59 | .client(api_client) 60 | .addConverterFactory(api_factory) 61 | .build() 62 | 63 | private val service = retrofit.create(API::class.java) 64 | 65 | private interface API { 66 | @GET("shipments/tracking/{id}") 67 | suspend fun getParcel(@Path("id") trackingId: String): ParcelData 68 | } 69 | 70 | @JsonClass(generateAdapter = true) 71 | internal data class ParcelData( 72 | val number: String, 73 | val scheduled_delivery_date: String, // UTC ISO 8601 date 74 | val tracking: List, 75 | ) 76 | 77 | @JsonClass(generateAdapter = true) 78 | internal data class ParcelEvent( 79 | val code: String, 80 | val event_name: String, 81 | val date: String, // UTC ISO 8601 date 82 | val settlement_name: String, 83 | ) 84 | } 85 | -------------------------------------------------------------------------------- /app/src/main/java/dev/itsvic/parceltracker/api/PacketaDeliveryService.kt: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | package dev.itsvic.parceltracker.api 3 | 4 | import android.os.LocaleList 5 | import com.squareup.moshi.JsonClass 6 | import dev.itsvic.parceltracker.R 7 | import java.time.LocalDateTime 8 | import java.time.format.DateTimeFormatter 9 | import okio.IOException 10 | import retrofit2.HttpException 11 | import retrofit2.Retrofit 12 | import retrofit2.converter.moshi.MoshiConverterFactory 13 | import retrofit2.http.POST 14 | import retrofit2.http.Path 15 | 16 | // reverse engineered from their Nuxt app lol 17 | object PacketaDeliveryService : DeliveryService { 18 | override val nameResource: Int = R.string.service_packeta 19 | override val acceptsPostCode: Boolean = false 20 | override val requiresPostCode: Boolean = false 21 | 22 | override fun acceptsFormat(trackingId: String): Boolean { 23 | return """^Z\s?\d{3}\s?\d{4}\s?\d{3}$""".toRegex().matchEntire(trackingId) != null 24 | } 25 | 26 | override suspend fun getParcel(trackingId: String, postalCode: String?): Parcel { 27 | val cleanId = trackingId.replace(Regex("\\s"), "").lowercase().replace("z", "") 28 | val locale = LocaleList.getDefault().get(0) 29 | 30 | val resp = 31 | try { 32 | service.getPacketById(cleanId, locale.language) 33 | } catch (e: HttpException) { 34 | if (e.code() == 400 || e.code() == 404) throw ParcelNonExistentException() 35 | else throw IOException() 36 | } 37 | 38 | val history = 39 | resp.item.trackingDetails.reversed().map { 40 | ParcelHistoryItem( 41 | it.text, 42 | LocalDateTime.parse(it.time.replace(' ', 'T'), DateTimeFormatter.ISO_DATE_TIME), 43 | // location is not its own field, and regex is unreliable 44 | // as addresses are just tacked on the end with inconsistent formatting 45 | "") 46 | } 47 | 48 | val status = 49 | when (resp.item.packetStatusId) { 50 | // guesswork from the enum found in packeta web tracker code 51 | "1" -> Status.InWarehouse // WAITING_FOR_DELIVERY 52 | "2" -> Status.AwaitingPickup // READY_FOR_PICKUP 53 | "3" -> Status.Delivered // ISSUED_AND_ACCOUNTED 54 | "21" -> Status.Unknown // LOST_OR_UNKNOWN 55 | "31" -> Status.InTransit // ON_THE_WAY 56 | "997" -> Status.InTransit // TO_BE_PROCESSED 57 | else -> logUnknownStatus("Packeta", resp.item.packetStatusId) 58 | } 59 | 60 | return Parcel(resp.item.barcode, history, status) 61 | } 62 | 63 | private val retrofit = 64 | Retrofit.Builder() 65 | .baseUrl("https://tracking.packeta.com/api/") 66 | .client(api_client) 67 | .addConverterFactory(MoshiConverterFactory.create(api_moshi)) 68 | .build() 69 | 70 | private val service = retrofit.create(API::class.java) 71 | 72 | private interface API { 73 | @POST("getPacketById/{id}/{locale}") 74 | suspend fun getPacketById( 75 | @Path("id") id: String, 76 | @Path("locale") locale: String 77 | ): PacketResponse 78 | } 79 | 80 | @JsonClass(generateAdapter = true) 81 | internal data class PacketResponse( 82 | val item: Packet, 83 | ) 84 | 85 | @JsonClass(generateAdapter = true) 86 | internal data class Packet( 87 | val barcode: String, 88 | val packetStatusId: String, 89 | val trackingDetails: List, 90 | ) 91 | 92 | @JsonClass(generateAdapter = true) 93 | internal data class Event( 94 | val text: String, 95 | val time: String, 96 | ) 97 | } 98 | -------------------------------------------------------------------------------- /app/src/main/java/dev/itsvic/parceltracker/api/PolishPostDeliveryService.kt: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | package dev.itsvic.parceltracker.api 3 | 4 | import android.os.LocaleList 5 | import com.squareup.moshi.JsonClass 6 | import dev.itsvic.parceltracker.R 7 | import java.time.LocalDateTime 8 | import java.time.format.DateTimeFormatter 9 | import retrofit2.Retrofit 10 | import retrofit2.http.Body 11 | import retrofit2.http.Headers 12 | import retrofit2.http.POST 13 | 14 | // Reverse-engineered from https://emonitoring.poczta-polska.pl 15 | object PolishPostDeliveryService : DeliveryService { 16 | override val nameResource: Int = R.string.service_polish_post 17 | override val acceptsPostCode: Boolean = false 18 | override val requiresPostCode: Boolean = false 19 | 20 | override fun acceptsFormat(trackingId: String): Boolean { 21 | val pocztexRegex = """^PX\d{10}$""".toRegex() 22 | return pocztexRegex.matchEntire(trackingId) != null || emsFormat.matchEntire(trackingId) != null 23 | } 24 | 25 | override suspend fun getParcel(trackingId: String, postalCode: String?): Parcel { 26 | val locale = LocaleList.getDefault().get(0).language 27 | val req = ParcelRequest(trackingId, if (locale == "pl") "X1" else "EN", true) 28 | val resp = 29 | try { 30 | service.getParcel(req) 31 | } catch (_: Exception) { 32 | throw ParcelNonExistentException() 33 | } 34 | 35 | if (resp.mailInfo == null || resp.mailStatus == -1) throw ParcelNonExistentException() 36 | val history = 37 | resp.mailInfo.events.reversed().map { item -> 38 | ParcelHistoryItem( 39 | item.name, 40 | LocalDateTime.parse(item.time, DateTimeFormatter.ISO_DATE_TIME), 41 | if (item.postOffice.description != null) 42 | "${item.postOffice.name}\n${item.postOffice.description.street} ${item.postOffice.description.houseNumber}\n${item.postOffice.description.zipCode} ${item.postOffice.description.city}" 43 | else item.postOffice.name) 44 | } 45 | 46 | val status = 47 | when (resp.mailInfo.events.last().code) { 48 | "P_NAD", 49 | "P_REJ_KN1" -> Status.Preadvice 50 | "P_ZWC" -> Status.Customs 51 | "P_ZWOLDDOR" -> Status.Customs 52 | "P_WPUCPP" -> Status.Customs 53 | "P_WZL" -> Status.InTransit 54 | "P_WD", 55 | "P_WDML" -> Status.OutForDelivery 56 | "P_D" -> Status.Delivered 57 | "P_A" -> Status.DeliveryFailure 58 | "P_KWD" -> Status.AwaitingPickup 59 | "P_OWU" -> Status.PickedUp 60 | // Post-delivery customs declaration(?) 61 | "P_ROZL_CEL" -> Status.Delivered 62 | else -> logUnknownStatus("Polish Post", resp.mailInfo.events.last().code) 63 | } 64 | 65 | return Parcel(resp.number, history, status) 66 | } 67 | 68 | private val retrofit = 69 | Retrofit.Builder() 70 | .baseUrl("https://uss.poczta-polska.pl/uss/v1.1/") 71 | .client(api_client) 72 | .addConverterFactory(api_factory) 73 | .build() 74 | 75 | private val service = retrofit.create(API::class.java) 76 | 77 | private const val API_KEY = 78 | "BiGwVG2XHvXY+kPwJVPA8gnKchOFsyy39Thkyb1wAiWcKLQ1ICyLiCrxj1+vVGC+kQk3k0b74qkmt5/qVIzo7lTfXhfgJ72Iyzz05wH2XZI6AgXVDciX7G2jLCdoOEM6XegPsMJChiouWS2RZuf3eOXpK5RPl8Sy4pWj+b07MLg=.Mjg0Q0NFNzM0RTBERTIwOTNFOUYxNkYxMUY1NDZGMTA0NDMwQUIyRjg4REUxMjk5NDAyMkQ0N0VCNDgwNTc1NA==.b24415d1b30a456cb8ba187b34cb6a86" 79 | 80 | private interface API { 81 | @POST("tracking/checkmailex") 82 | @Headers("Api_key: $API_KEY") 83 | suspend fun getParcel(@Body data: ParcelRequest): ParcelResponse 84 | } 85 | 86 | @JsonClass(generateAdapter = true) 87 | internal data class ParcelRequest( 88 | val number: String, 89 | val language: String, // X1 - Polish(?), EN - English 90 | val addPostOfficeInfo: Boolean, 91 | ) 92 | 93 | @JsonClass(generateAdapter = true) 94 | internal data class ParcelResponse( 95 | val mailInfo: MailInfo?, 96 | val mailStatus: Int, 97 | val number: String, 98 | ) 99 | 100 | @JsonClass(generateAdapter = true) 101 | internal data class MailInfo( 102 | val number: String, 103 | val events: List, 104 | ) 105 | 106 | @JsonClass(generateAdapter = true) 107 | internal data class Event( 108 | val code: String, 109 | val name: String, 110 | val postOffice: PostOffice, 111 | val time: String, 112 | ) 113 | 114 | @JsonClass(generateAdapter = true) 115 | internal data class PostOffice( 116 | val name: String, 117 | val description: PostOfficeDescription?, 118 | ) 119 | 120 | @JsonClass(generateAdapter = true) 121 | internal data class PostOfficeDescription( 122 | val city: String, 123 | val houseNumber: String, 124 | val street: String, 125 | val zipCode: String, 126 | ) 127 | } 128 | -------------------------------------------------------------------------------- /app/src/main/java/dev/itsvic/parceltracker/api/PostNordDeliveryService.kt: -------------------------------------------------------------------------------- 1 | package dev.itsvic.parceltracker.api 2 | 3 | import android.os.LocaleList 4 | import com.squareup.moshi.JsonClass 5 | import dev.itsvic.parceltracker.R 6 | import java.io.IOException 7 | import java.time.ZoneId 8 | import java.time.ZonedDateTime 9 | import retrofit2.HttpException 10 | import retrofit2.Retrofit 11 | import retrofit2.http.GET 12 | import retrofit2.http.Headers 13 | import retrofit2.http.Query 14 | 15 | // Reverse-engineered from https://www.postnord.com/track-and-trace/ 16 | // And additional information about locales from 17 | // https://developer.postnord.com/apis/details?systemName=shipment-v5-trackandtrace-shipmentinformation 18 | object PostNordDeliveryService : DeliveryService { 19 | override val nameResource: Int = R.string.service_postnord 20 | override val acceptsPostCode: Boolean = false 21 | override val requiresPostCode: Boolean = false 22 | 23 | // Define supported locales and default 24 | private const val DEFAULT_LOCALE = "en" 25 | private val supportedLocales = setOf("en", "sv", "no", "da", "fi") 26 | 27 | private fun getApiLocale(): String { 28 | val locale = LocaleList.getDefault().get(0).language 29 | return if (supportedLocales.contains(locale)) locale else DEFAULT_LOCALE 30 | } 31 | 32 | private val statusMapping = 33 | mapOf( 34 | "CREATED" to Status.Preadvice, 35 | "EN_ROUTE" to Status.InTransit, 36 | "DELAYED" to Status.InTransit, 37 | "EXPECTED_DELAY" to Status.InTransit, 38 | "AVAILABLE_FOR_DELIVERY" to Status.InWarehouse, 39 | "AVAILABLE_FOR_DELIVERY_PAR_LOC" to Status.InWarehouse, 40 | "DELIVERED" to Status.Delivered, 41 | "DELIVERY_IMPOSSIBLE" to Status.DeliveryFailure, 42 | "DELIVERY_REFUSED" to Status.DeliveryFailure, 43 | "STOPPED" to Status.DeliveryFailure, 44 | "RETURNED" to Status.DeliveryFailure, 45 | "OTHER" to Status.Unknown, 46 | "INFORMED" to Status.Unknown) 47 | 48 | override suspend fun getParcel(trackingId: String, postCode: String?): Parcel { 49 | val resp = 50 | try { 51 | service.getShipments(trackingId, getApiLocale()) 52 | } catch (e: HttpException) { 53 | when (e.code()) { 54 | 404 -> throw ParcelNonExistentException() 55 | else -> throw IOException("Failed to fetch parcel information: ${e.message()}") 56 | } 57 | } 58 | 59 | val item = resp.items.firstOrNull() ?: throw ParcelNonExistentException() 60 | 61 | val status = statusMapping[item.status.code] ?: logUnknownStatus("PostNord", item.status.code) 62 | 63 | val history = 64 | item.events.map { 65 | ParcelHistoryItem( 66 | it.eventDescription, 67 | ZonedDateTime.parse(it.eventTime) 68 | .withZoneSameInstant(ZoneId.systemDefault()) 69 | .toLocalDateTime(), 70 | listOfNotNull(it.location.name, it.location.countryCode).joinToString(", ")) 71 | } 72 | 73 | return Parcel(resp.shipmentId, history, status) 74 | } 75 | 76 | private val retrofit = 77 | Retrofit.Builder() 78 | .baseUrl("https://api2.postnord.com/rest/shipment/v1/trackingweb/") 79 | .client(api_client) 80 | .addConverterFactory(api_factory) 81 | .build() 82 | 83 | private val service = retrofit.create(API::class.java) 84 | 85 | private interface API { 86 | @GET("shipmentInformation") 87 | @Headers("x-bap-key: web-tracking-sc") 88 | suspend fun getShipments( 89 | @Query("shipmentId") id: String, 90 | @Query("locale") locale: String 91 | ): ShipmentResponse 92 | } 93 | 94 | @JsonClass(generateAdapter = true) 95 | internal data class ShipmentResponse(val shipmentId: String, val items: List) 96 | 97 | @JsonClass(generateAdapter = true) 98 | internal data class Item( 99 | val itemId: String, 100 | val deliveryInformation: DeliveryInformation, 101 | val events: List, 102 | val status: ItemStatus 103 | ) 104 | 105 | @JsonClass(generateAdapter = true) 106 | internal data class DeliveryInformation( 107 | val deliveryTo: String, 108 | val deliveryToInfo: String, 109 | ) 110 | 111 | @JsonClass(generateAdapter = true) 112 | internal data class Event( 113 | val eventDescription: String, 114 | val eventTime: String, // ISO-8601 representation of the datetime 115 | val status: String, 116 | val location: Location 117 | ) 118 | 119 | @JsonClass(generateAdapter = true) 120 | internal data class Location( 121 | val countryCode: String?, 122 | val locationType: String?, 123 | val name: String? 124 | ) 125 | 126 | @JsonClass(generateAdapter = true) 127 | internal data class ItemStatus(val code: String, val header: String, val description: String) 128 | } 129 | -------------------------------------------------------------------------------- /app/src/main/java/dev/itsvic/parceltracker/api/PosteItalianeDeliveryService.kt: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | package dev.itsvic.parceltracker.api 3 | 4 | import com.squareup.moshi.JsonClass 5 | import dev.itsvic.parceltracker.R 6 | import java.time.Instant 7 | import java.time.LocalDateTime 8 | import java.util.TimeZone 9 | import retrofit2.HttpException 10 | import retrofit2.Retrofit 11 | import retrofit2.http.Body 12 | import retrofit2.http.POST 13 | 14 | // Poste Italiane (Italian Post) 15 | // fucking asshats with their shitty italian API 16 | 17 | object PosteItalianeDeliveryService : DeliveryService { 18 | override val nameResource: Int = R.string.service_poste_italiane 19 | override val acceptsPostCode: Boolean = false 20 | override val requiresPostCode: Boolean = false 21 | 22 | override suspend fun getParcel(trackingId: String, postalCode: String?): Parcel { 23 | val resp = 24 | try { 25 | service.getParcel(GetParcelRequest(trackingId)) 26 | } catch (_: HttpException) { 27 | throw ParcelNonExistentException() 28 | } 29 | 30 | if (resp.listaMovimenti.isNullOrEmpty()) { 31 | throw ParcelNonExistentException() 32 | } 33 | 34 | val events = 35 | resp.listaMovimenti.reversed().map { 36 | ParcelHistoryItem( 37 | it.statoLavorazione, 38 | LocalDateTime.ofInstant( 39 | Instant.ofEpochMilli(it.dataOra), TimeZone.getDefault().toZoneId()), 40 | it.luogo, 41 | ) 42 | } 43 | 44 | val status = 45 | when (resp.listaMovimenti.last().box) { 46 | "3" -> Status.InTransit 47 | "4" -> Status.DeliveryFailure 48 | "5" -> Status.Delivered 49 | else -> logUnknownStatus("Italian Post", resp.listaMovimenti.last().box) 50 | } 51 | 52 | return Parcel(trackingId, events, status) 53 | } 54 | 55 | private val retrofit = 56 | Retrofit.Builder() 57 | .baseUrl("https://www.poste.it/online/dovequando/DQ-REST/") 58 | .client(api_client) 59 | .addConverterFactory(api_factory) 60 | .build() 61 | 62 | private val service = retrofit.create(API::class.java) 63 | 64 | private interface API { 65 | @POST("ricercasemplice") suspend fun getParcel(@Body data: GetParcelRequest): GetParcelResponse 66 | } 67 | 68 | @JsonClass(generateAdapter = true) 69 | internal data class GetParcelRequest( 70 | val codiceSpedizione: String, 71 | val tipoRichiedente: String = "WEB", 72 | val periodoRicerca: Int = 1, 73 | ) 74 | 75 | @JsonClass(generateAdapter = true) 76 | internal data class GetParcelResponse( 77 | /** movement list */ 78 | val listaMovimenti: List?, 79 | ) 80 | 81 | @JsonClass(generateAdapter = true) 82 | internal data class ParcelEvent( 83 | /** possibly state of the event? */ 84 | val box: String, 85 | /** event date, epoch with milliseconds */ 86 | val dataOra: Long, 87 | /** return flag */ 88 | val flagRitorno: Boolean, 89 | /** post code */ 90 | val frazionario: String?, 91 | /** place */ 92 | val luogo: String, 93 | /** state in italian */ 94 | val statoLavorazione: String, 95 | ) 96 | } 97 | -------------------------------------------------------------------------------- /app/src/main/java/dev/itsvic/parceltracker/api/SPXDeliveryService.kt: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | package dev.itsvic.parceltracker.api 3 | 4 | import com.squareup.moshi.JsonClass 5 | import dev.itsvic.parceltracker.R 6 | import java.security.MessageDigest 7 | import java.time.Instant 8 | import java.time.LocalDateTime 9 | import java.util.TimeZone 10 | import retrofit2.HttpException 11 | import retrofit2.Retrofit 12 | import retrofit2.http.GET 13 | import retrofit2.http.Header 14 | import retrofit2.http.Query 15 | 16 | object SPXThailandDeliveryService : 17 | SPXDeliveryService( 18 | "https://spx.co.th/api/v2/", 19 | "MGViZmZmZTYzZDJhNDgxY2Y1N2ZlN2Q1ZWJkYzlmZDY=", 20 | R.string.service_spx_th) 21 | 22 | open class SPXDeliveryService( 23 | baseURL: String, 24 | private val magicValue: String, 25 | override val nameResource: Int 26 | ) : DeliveryService { 27 | override val acceptsPostCode: Boolean = false 28 | override val requiresPostCode: Boolean = false 29 | 30 | override suspend fun getParcel(trackingId: String, postalCode: String?): Parcel { 31 | val timestamp = (System.currentTimeMillis() / 1000).toString() 32 | val checksum = 33 | MessageDigest.getInstance("SHA-256") 34 | .digest("$trackingId$timestamp$magicValue".toByteArray()) 35 | .fold("") { str, it -> str + "%02x".format(it) } 36 | 37 | val slsTrackingNumber = "$trackingId|$timestamp$checksum" 38 | val resp = 39 | try { 40 | service.getParcel(slsTrackingNumber) 41 | } catch (_: HttpException) { 42 | throw ParcelNonExistentException() 43 | } 44 | 45 | if (resp.data == null) throw ParcelNonExistentException() 46 | 47 | val history = 48 | resp.data.tracking_list.map { 49 | ParcelHistoryItem( 50 | // shit patch for their shit bug 51 | it.message.replace("\\\\n", ""), 52 | LocalDateTime.ofInstant( 53 | Instant.ofEpochMilli(it.timestamp * 1000L), TimeZone.getDefault().toZoneId()), 54 | "") 55 | } 56 | 57 | val status = 58 | when (resp.data.current_status) { 59 | "Created" -> Status.Preadvice 60 | "Pending_Receive" -> Status.InTransit 61 | "Pending" -> Status.InWarehouse 62 | "Assigned" -> Status.OutForDelivery 63 | "Delivered" -> Status.Delivered 64 | else -> logUnknownStatus("SPX", resp.data.current_status) 65 | } 66 | 67 | return Parcel(resp.data.sls_tracking_number, history, status) 68 | } 69 | 70 | private val retrofit = 71 | Retrofit.Builder() 72 | .baseUrl(baseURL) 73 | .client(api_client) 74 | .addConverterFactory(api_factory) 75 | .build() 76 | 77 | private val service = retrofit.create(API::class.java) 78 | 79 | private interface API { 80 | @GET("fleet_order/tracking/search") 81 | suspend fun getParcel( 82 | @Query("sls_tracking_number") trackingNumber: String, 83 | @Header("x-language") language: String = "en", 84 | ): GetParcelResponse 85 | } 86 | 87 | @JsonClass(generateAdapter = true) 88 | internal data class GetParcelResponse( 89 | val data: ParcelData?, 90 | ) 91 | 92 | @JsonClass(generateAdapter = true) 93 | internal data class ParcelData( 94 | val current_status: String, 95 | val sls_tracking_number: String, 96 | val tracking_list: List, 97 | ) 98 | 99 | @JsonClass(generateAdapter = true) 100 | internal data class ParcelTrackingEvent( 101 | val message: String, 102 | val timestamp: Long, 103 | ) 104 | } 105 | -------------------------------------------------------------------------------- /app/src/main/java/dev/itsvic/parceltracker/api/SamedayDeliveryService.kt: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | package dev.itsvic.parceltracker.api 3 | 4 | import com.squareup.moshi.JsonClass 5 | import dev.itsvic.parceltracker.R 6 | import java.time.ZoneId 7 | import java.time.ZonedDateTime 8 | import retrofit2.HttpException 9 | import retrofit2.Retrofit 10 | import retrofit2.http.GET 11 | import retrofit2.http.Path 12 | 13 | object SamedayRomaniaDeliveryService : SamedayDeliveryService("ro", R.string.service_sameday_ro) 14 | 15 | object SamedayHungaryDeliveryService : SamedayDeliveryService("hu", R.string.service_sameday_hu) 16 | 17 | object SamedayBulgariaDeliveryService : SamedayDeliveryService("bg", R.string.service_sameday_bg) 18 | 19 | open class SamedayDeliveryService(region: String, override val nameResource: Int) : 20 | DeliveryService { 21 | override val acceptsPostCode: Boolean = false 22 | override val requiresPostCode: Boolean = false 23 | 24 | override suspend fun getParcel(trackingId: String, postalCode: String?): Parcel { 25 | val resp = 26 | try { 27 | service.getAwbHistory(trackingId) 28 | } catch (_: HttpException) { 29 | throw ParcelNonExistentException() 30 | } 31 | 32 | val history = 33 | resp.awbHistory.map { 34 | ParcelHistoryItem( 35 | it.status, 36 | // i'm sure there's a more concise way of doing this 37 | ZonedDateTime.parse(it.statusDate) 38 | .toInstant() 39 | .atZone(ZoneId.systemDefault()) 40 | .toLocalDateTime(), 41 | if (it.transitLocation.isNotBlank()) 42 | "${it.transitLocation}, ${it.county}, ${it.country}" 43 | else if (it.county.isNotBlank()) "${it.county}, ${it.country}" 44 | else if (it.country.isNotBlank()) it.country else "") 45 | } 46 | 47 | val status = 48 | when (val id = resp.awbHistory.first().statusStateId) { 49 | 1 -> Status.Preadvice 50 | 2 -> Status.InTransit 51 | 7 -> Status.InTransit 52 | 3 -> Status.InWarehouse 53 | 4 -> Status.OutForDelivery 54 | 18 -> Status.AwaitingPickup 55 | 5 -> Status.Delivered 56 | else -> logUnknownStatus("Sameday", id.toString()) 57 | } 58 | 59 | return Parcel(trackingId, history, status) 60 | } 61 | 62 | private val retrofit = 63 | Retrofit.Builder() 64 | .baseUrl("https://api.sameday.${region}/api/public/") 65 | .client(api_client) 66 | .addConverterFactory(api_factory) 67 | .build() 68 | 69 | private val service = retrofit.create(API::class.java) 70 | 71 | private interface API { 72 | @GET("awb/{id}/awb-history") suspend fun getAwbHistory(@Path("id") id: String): HistoryResponse 73 | } 74 | 75 | @JsonClass(generateAdapter = true) 76 | internal data class HistoryResponse( 77 | val awbHistory: List, 78 | ) 79 | 80 | @JsonClass(generateAdapter = true) 81 | internal data class Event( 82 | val country: String, 83 | val county: String, 84 | val status: String, 85 | val statusDate: String, 86 | val statusStateId: Int, 87 | val transitLocation: String, 88 | ) 89 | } 90 | -------------------------------------------------------------------------------- /app/src/main/java/dev/itsvic/parceltracker/api/UkrposhtaDeliveryService.kt: -------------------------------------------------------------------------------- 1 | package dev.itsvic.parceltracker.api 2 | 3 | import com.squareup.moshi.JsonClass 4 | import dev.itsvic.parceltracker.R 5 | import java.time.LocalDateTime 6 | import retrofit2.HttpException 7 | import retrofit2.Retrofit 8 | import retrofit2.http.GET 9 | import retrofit2.http.Headers 10 | import retrofit2.http.Query 11 | 12 | object UkrposhtaDeliveryService : DeliveryService { 13 | override val nameResource: Int = R.string.service_ukrposhta 14 | override val acceptsPostCode: Boolean = false 15 | override val requiresPostCode: Boolean = false 16 | 17 | private const val API_KEY = "c3e02b53-3b1d-386e-b676-141ffa054c57" 18 | 19 | override fun acceptsFormat(trackingId: String): Boolean { 20 | return emsFormat.accepts(trackingId) 21 | } 22 | 23 | override suspend fun getParcel(trackingId: String, postalCode: String?): Parcel { 24 | val resp = 25 | try { 26 | service.getStatuses(trackingId) 27 | } catch (_: HttpException) { 28 | throw ParcelNonExistentException() 29 | } 30 | 31 | val history = 32 | resp.reversed().map { 33 | ParcelHistoryItem(it.eventName, LocalDateTime.parse(it.date), "${it.name}, ${it.country}") 34 | } 35 | 36 | val status = 37 | when (resp.last().event) { 38 | 10100 -> Status.Preadvice 39 | 20700, 40 | 20800, 41 | 21500 -> Status.InTransit 42 | 21700, 43 | 21400 -> Status.InWarehouse 44 | 31100, 45 | 31300, 46 | 31400 -> Status.DeliveryFailure 47 | 41000, 48 | 48000 -> Status.Delivered 49 | else -> logUnknownStatus("Ukrposhta", "${resp.last().event} (${resp.last().eventName}") 50 | } 51 | 52 | return Parcel(resp.first().barcode, history, status) 53 | } 54 | 55 | private val retrofit = 56 | Retrofit.Builder() 57 | .baseUrl("https://www.ukrposhta.ua/status-tracking/0.0.1/") 58 | .client(api_client) 59 | .addConverterFactory(api_factory) 60 | .build() 61 | 62 | private val service = retrofit.create(API::class.java) 63 | 64 | private interface API { 65 | @GET("statuses") 66 | @Headers("Authorization: Bearer $API_KEY") 67 | suspend fun getStatuses( 68 | @Query("barcode") trackingId: String, 69 | @Query("lang") lang: String = "en", 70 | ): List 71 | } 72 | 73 | @JsonClass(generateAdapter = true) 74 | internal data class ParcelStatus( 75 | val barcode: String, 76 | val date: String, // LocalDateTime according to docs 77 | val name: String, // name of the post office 78 | val country: String, // country of shipment location 79 | val event: Int, 80 | val eventName: String, 81 | ) 82 | } 83 | -------------------------------------------------------------------------------- /app/src/main/java/dev/itsvic/parceltracker/api/UniUniDeliveryService.kt: -------------------------------------------------------------------------------- 1 | package dev.itsvic.parceltracker.api 2 | 3 | import com.squareup.moshi.JsonClass 4 | import dev.itsvic.parceltracker.R 5 | import kotlinx.coroutines.ExperimentalCoroutinesApi 6 | import okhttp3.Request 7 | import okhttp3.coroutines.executeAsync 8 | import retrofit2.HttpException 9 | import retrofit2.Retrofit 10 | import retrofit2.http.GET 11 | import retrofit2.http.Query 12 | 13 | object UniUniDeliveryService : DeliveryService { 14 | override val nameResource: Int = R.string.service_uniuni 15 | override val acceptsPostCode: Boolean = false 16 | override val requiresPostCode: Boolean = false 17 | 18 | override suspend fun getParcel(trackingId: String, postalCode: String?): Parcel { 19 | val key = getRequestKey() 20 | if (key == null) { 21 | // TODO: use a proper exception here 22 | throw ParcelNonExistentException() 23 | } 24 | 25 | val resp = 26 | try { 27 | service.getParcelInfo(trackingId, key) 28 | } catch (_: HttpException) { 29 | throw ParcelNonExistentException() 30 | } 31 | 32 | if (resp.data.valid_tno.isEmpty()) { 33 | throw ParcelNonExistentException() 34 | } 35 | 36 | val parcel = resp.data.valid_tno.first() 37 | 38 | val history = 39 | parcel.spath_list.reversed().map { 40 | ParcelHistoryItem(it.pathInfo, localDateFromMilli(it.pathTime * 1000), it.pathAddress) 41 | } 42 | 43 | // https://docs.uniuni.com/#d0cdf4e9-53d8-4a09-b4cb-db2187d36877 44 | val status = 45 | when (parcel.state) { 46 | 190, 47 | 223 -> Status.Preadvice 48 | 192, 49 | 198 -> Status.Customs 50 | 199, 51 | 200, 52 | 218, 53 | 221, 54 | 229 -> Status.InWarehouse 55 | 195, 56 | 204, 57 | 217, 58 | 225, 59 | 255 -> Status.InTransit 60 | 202 -> Status.OutForDelivery 61 | 203, 62 | 228 -> Status.Delivered 63 | 206, 64 | 207, 65 | 209, 66 | 211, 67 | 212, 68 | 213, 69 | 215, 70 | 222, 71 | 224, 72 | 230, 73 | 231, 74 | 232 -> Status.DeliveryFailure 75 | 214, 76 | 226 -> Status.AwaitingPickup 77 | 216 -> Status.PickedUp 78 | else -> logUnknownStatus("UniUni", parcel.state.toString()) 79 | } 80 | 81 | return Parcel(parcel.tno, history, status) 82 | } 83 | 84 | @OptIn(ExperimentalCoroutinesApi::class) 85 | suspend fun getRequestKey(): String? { 86 | val keyRegex = """(?<=&key=)[^"]*""".toRegex() 87 | val request = Request.Builder().url("https://www.uniuni.com/tracking/").build() 88 | 89 | api_client.newCall(request).executeAsync().use { response -> 90 | val result = keyRegex.find(response.body.string()) 91 | if (result == null) return null 92 | return result.value 93 | } 94 | } 95 | 96 | private val retrofit = 97 | Retrofit.Builder() 98 | .baseUrl("https://delivery-api.uniuni.ca/cargo/") 99 | .client(api_client) 100 | .addConverterFactory(api_factory) 101 | .build() 102 | 103 | private val service = retrofit.create(API::class.java) 104 | 105 | private interface API { 106 | @GET("trackinguniuninew") 107 | suspend fun getParcelInfo( 108 | @Query("id") trackingId: String, 109 | @Query("key") apiKey: String, 110 | ): GetParcelResponse 111 | } 112 | 113 | @JsonClass(generateAdapter = true) 114 | internal data class GetParcelResponse(val data: GetParcelResponseData) 115 | 116 | @JsonClass(generateAdapter = true) 117 | internal data class GetParcelResponseData( 118 | val valid_tno: List, 119 | ) 120 | 121 | @JsonClass(generateAdapter = true) 122 | internal data class ParcelInfo( 123 | val tno: String, 124 | val state: Int, 125 | val spath_list: List, 126 | ) 127 | 128 | @JsonClass(generateAdapter = true) 129 | internal data class ParcelEvent( 130 | val pathTime: Long, 131 | val pathInfo: String, 132 | val pathAddress: String, 133 | ) 134 | } 135 | -------------------------------------------------------------------------------- /app/src/main/java/dev/itsvic/parceltracker/db/AppDatabase.kt: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | package dev.itsvic.parceltracker.db 3 | 4 | import androidx.room.AutoMigration 5 | import androidx.room.Database 6 | import androidx.room.RoomDatabase 7 | import androidx.room.TypeConverters 8 | 9 | @Database( 10 | version = 4, 11 | entities = [Parcel::class, ParcelStatus::class, ParcelHistoryItem::class], 12 | autoMigrations = 13 | [ 14 | AutoMigration(from = 1, to = 2), 15 | AutoMigration(from = 2, to = 3), 16 | AutoMigration(from = 2, to = 4), 17 | AutoMigration(from = 3, to = 4)]) 18 | @TypeConverters(Converters::class) 19 | abstract class AppDatabase : RoomDatabase() { 20 | abstract fun parcelDao(): ParcelDao 21 | 22 | abstract fun parcelStatusDao(): ParcelStatusDao 23 | 24 | abstract fun parcelHistoryDao(): ParcelHistoryDao 25 | } 26 | -------------------------------------------------------------------------------- /app/src/main/java/dev/itsvic/parceltracker/db/Converters.kt: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | package dev.itsvic.parceltracker.db 3 | 4 | import androidx.room.TypeConverter 5 | import java.time.Instant 6 | import java.time.LocalDateTime 7 | 8 | class Converters { 9 | @TypeConverter 10 | fun fromTimestamp(value: Long?): Instant? { 11 | return value?.let { Instant.ofEpochMilli(it) } 12 | } 13 | 14 | @TypeConverter 15 | fun instantToTimestamp(instant: Instant?): Long? { 16 | return instant?.toEpochMilli() 17 | } 18 | 19 | @TypeConverter 20 | fun dateTimeFromString(value: String?): LocalDateTime? { 21 | return value?.let { LocalDateTime.parse(it) } 22 | } 23 | 24 | @TypeConverter 25 | fun dateTimeToString(value: LocalDateTime?): String? { 26 | return value?.toString() 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/src/main/java/dev/itsvic/parceltracker/db/DemoMode.kt: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | package dev.itsvic.parceltracker.db 3 | 4 | import dev.itsvic.parceltracker.api.Service 5 | import dev.itsvic.parceltracker.api.Status 6 | import java.time.Instant 7 | 8 | // Parcel metadata and mock DB for demo mode. 9 | 10 | private var demoId = 0 11 | 12 | private fun defineDemoParcel(name: String, id: String, status: Status): ParcelWithStatus { 13 | val internalId = demoId++ 14 | return ParcelWithStatus( 15 | Parcel(internalId, name, id, null, Service.EXAMPLE, archivePromptDismissed = true), 16 | ParcelStatus(internalId, status, Instant.now())) 17 | } 18 | 19 | val demoModeParcels = 20 | listOf( 21 | defineDemoParcel("Phone case", "2503894188", Status.Delivered), 22 | defineDemoParcel("Keyring tracker", "7301626157", Status.AwaitingPickup), 23 | defineDemoParcel("Game merch", "6171197286", Status.Preadvice), 24 | ) 25 | -------------------------------------------------------------------------------- /app/src/main/java/dev/itsvic/parceltracker/db/Helpers.kt: -------------------------------------------------------------------------------- 1 | package dev.itsvic.parceltracker.db 2 | 3 | import dev.itsvic.parceltracker.ParcelApplication 4 | 5 | suspend fun deleteParcel(parcel: Parcel) { 6 | val db = ParcelApplication.db 7 | val parcelId = ParcelId(parcel.id) 8 | db.parcelDao().delete(parcel) 9 | db.parcelStatusDao().deleteByParcelId(parcelId) 10 | if (parcel.isArchived) db.parcelHistoryDao().deleteByParcelId(parcelId) 11 | } 12 | -------------------------------------------------------------------------------- /app/src/main/java/dev/itsvic/parceltracker/db/Parcel.kt: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | package dev.itsvic.parceltracker.db 3 | 4 | import androidx.room.ColumnInfo 5 | import androidx.room.Dao 6 | import androidx.room.Delete 7 | import androidx.room.Embedded 8 | import androidx.room.Entity 9 | import androidx.room.Insert 10 | import androidx.room.PrimaryKey 11 | import androidx.room.Query 12 | import androidx.room.Relation 13 | import androidx.room.Transaction 14 | import androidx.room.Update 15 | import dev.itsvic.parceltracker.api.Service 16 | import kotlinx.coroutines.flow.Flow 17 | 18 | @Entity 19 | data class Parcel( 20 | @PrimaryKey(autoGenerate = true) val id: Int = 0, 21 | val humanName: String, 22 | val parcelId: String, 23 | val postalCode: String?, 24 | val service: Service, 25 | @ColumnInfo(defaultValue = "0") val isArchived: Boolean = false, 26 | @ColumnInfo(defaultValue = "0") val archivePromptDismissed: Boolean = false, 27 | ) 28 | 29 | data class ParcelWithStatus( 30 | @Embedded val parcel: Parcel, 31 | @Relation(parentColumn = "id", entityColumn = "parcelId") val status: ParcelStatus?, 32 | ) 33 | 34 | @Dao 35 | interface ParcelDao { 36 | @Query("SELECT * FROM parcel") fun getAll(): Flow> 37 | 38 | @Transaction @Query("SELECT * FROM parcel") fun getAllWithStatus(): Flow> 39 | 40 | @Transaction 41 | @Query("SELECT * FROM parcel WHERE isArchived = 0") 42 | suspend fun getAllNonArchivedWithStatusAsync(): List 43 | 44 | @Query("SELECT * FROM parcel WHERE id=:id LIMIT 1") fun getById(id: Int): Flow 45 | 46 | @Transaction 47 | @Query("SELECT * FROM Parcel WHERE id=:id") 48 | fun getWithStatusById(id: Int): Flow 49 | 50 | @Insert suspend fun insert(parcel: Parcel): Long 51 | 52 | @Update suspend fun update(parcel: Parcel) 53 | 54 | @Delete suspend fun delete(parcel: Parcel) 55 | } 56 | -------------------------------------------------------------------------------- /app/src/main/java/dev/itsvic/parceltracker/db/ParcelHistoryItem.kt: -------------------------------------------------------------------------------- 1 | package dev.itsvic.parceltracker.db 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Delete 5 | import androidx.room.Entity 6 | import androidx.room.Insert 7 | import androidx.room.PrimaryKey 8 | import androidx.room.Query 9 | import java.time.LocalDateTime 10 | import kotlinx.coroutines.flow.Flow 11 | 12 | @Entity 13 | data class ParcelHistoryItem( 14 | @PrimaryKey(autoGenerate = true) val id: Int = 0, 15 | val parcelId: Int, 16 | val description: String, 17 | val time: LocalDateTime, 18 | val location: String, 19 | ) 20 | 21 | data class ParcelId( 22 | val parcelId: Int, 23 | ) 24 | 25 | @Dao 26 | interface ParcelHistoryDao { 27 | @Query("SELECT * FROM parcelhistoryitem WHERE parcelId=:id") 28 | fun getAllById(id: Int): Flow> 29 | 30 | @Insert suspend fun insert(items: List) 31 | 32 | @Delete(entity = ParcelHistoryItem::class) suspend fun deleteByParcelId(parcelId: ParcelId) 33 | } 34 | -------------------------------------------------------------------------------- /app/src/main/java/dev/itsvic/parceltracker/db/ParcelStatus.kt: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | package dev.itsvic.parceltracker.db 3 | 4 | import androidx.room.Dao 5 | import androidx.room.Delete 6 | import androidx.room.Entity 7 | import androidx.room.Insert 8 | import androidx.room.PrimaryKey 9 | import androidx.room.Query 10 | import androidx.room.Update 11 | import dev.itsvic.parceltracker.api.Status 12 | import java.time.Instant 13 | 14 | @Entity 15 | data class ParcelStatus( 16 | @PrimaryKey val parcelId: Int, 17 | val status: Status, 18 | val lastChange: Instant, 19 | ) 20 | 21 | @Dao 22 | interface ParcelStatusDao { 23 | @Query("SELECT * FROM ParcelStatus WHERE parcelId=:parcelId") 24 | suspend fun get(parcelId: Int): ParcelStatus 25 | 26 | @Insert suspend fun insert(status: ParcelStatus) 27 | 28 | @Update suspend fun update(status: ParcelStatus) 29 | 30 | @Delete(entity = ParcelStatus::class) suspend fun deleteByParcelId(parcelId: ParcelId) 31 | } 32 | -------------------------------------------------------------------------------- /app/src/main/java/dev/itsvic/parceltracker/misc/DefaultRegions.kt: -------------------------------------------------------------------------------- 1 | package dev.itsvic.parceltracker.misc 2 | 3 | // This is required for the UPS backend, because for some reason it requires 4 | // the region in the locale, and Java does not provide that info unless it was 5 | // provided to it beforehand. 6 | // tldr i18n sucks 7 | val defaultRegionsForLanguageCode = 8 | mapOf("pl" to "PL", "hu" to "HU", "uk" to "UA", "th" to "TH", "cs" to "CZ") 9 | -------------------------------------------------------------------------------- /app/src/main/java/dev/itsvic/parceltracker/ui/components/AboutDialog.kt: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | package dev.itsvic.parceltracker.ui.components 3 | 4 | import android.content.Context 5 | import android.net.Uri 6 | import androidx.browser.customtabs.CustomTabsIntent 7 | import androidx.compose.foundation.layout.Column 8 | import androidx.compose.foundation.layout.Spacer 9 | import androidx.compose.foundation.layout.fillMaxWidth 10 | import androidx.compose.foundation.layout.height 11 | import androidx.compose.foundation.layout.padding 12 | import androidx.compose.foundation.layout.width 13 | import androidx.compose.foundation.shape.RoundedCornerShape 14 | import androidx.compose.material3.Button 15 | import androidx.compose.material3.Card 16 | import androidx.compose.material3.Icon 17 | import androidx.compose.material3.MaterialTheme 18 | import androidx.compose.material3.Text 19 | import androidx.compose.runtime.Composable 20 | import androidx.compose.ui.Modifier 21 | import androidx.compose.ui.platform.LocalContext 22 | import androidx.compose.ui.res.painterResource 23 | import androidx.compose.ui.res.stringResource 24 | import androidx.compose.ui.text.style.TextAlign 25 | import androidx.compose.ui.tooling.preview.PreviewLightDark 26 | import androidx.compose.ui.unit.dp 27 | import androidx.compose.ui.window.Dialog 28 | import androidx.core.net.toUri 29 | import dev.itsvic.parceltracker.BuildConfig 30 | import dev.itsvic.parceltracker.R 31 | import dev.itsvic.parceltracker.ui.theme.ParcelTrackerTheme 32 | 33 | @Composable 34 | fun AboutDialog(onDismissRequest: () -> Unit) { 35 | val context = LocalContext.current 36 | 37 | Dialog(onDismissRequest = onDismissRequest) { 38 | Card(modifier = Modifier.fillMaxWidth().padding(16.dp), shape = RoundedCornerShape(16.dp)) { 39 | Column(modifier = Modifier.padding(24.dp)) { 40 | Text( 41 | text = "Parcel", 42 | style = MaterialTheme.typography.titleLarge, 43 | modifier = Modifier.fillMaxWidth(), 44 | textAlign = TextAlign.Center, 45 | ) 46 | Text( 47 | text = BuildConfig.VERSION_NAME, 48 | style = MaterialTheme.typography.titleSmall, 49 | color = MaterialTheme.colorScheme.onSurfaceVariant, 50 | modifier = Modifier.fillMaxWidth(), 51 | textAlign = TextAlign.Center, 52 | ) 53 | 54 | Spacer(Modifier.height(24.dp)) 55 | 56 | Button( 57 | onClick = { context.openLinkInBrowser("https://github.com/itsvic-dev/parcel".toUri()) }, 58 | modifier = Modifier.fillMaxWidth()) { 59 | Icon(painterResource(R.drawable.github), stringResource(R.string.repository)) 60 | Spacer(Modifier.width(8.dp)) 61 | Text(stringResource(R.string.repository)) 62 | } 63 | 64 | Button( 65 | onClick = { 66 | context.openLinkInBrowser( 67 | "https://github.com/itsvic-dev/parcel/blob/master/LICENSE.md".toUri()) 68 | }, 69 | modifier = Modifier.fillMaxWidth()) { 70 | Icon(painterResource(R.drawable.license_24px), stringResource(R.string.license)) 71 | Spacer(Modifier.width(8.dp)) 72 | Text(stringResource(R.string.license)) 73 | } 74 | 75 | Button( 76 | onClick = { 77 | context.openLinkInBrowser("https://github.com/sponsors/itsvic-dev".toUri()) 78 | }, 79 | modifier = Modifier.fillMaxWidth()) { 80 | Icon( 81 | painterResource(R.drawable.volunteer_activism_24px), 82 | stringResource(R.string.donate)) 83 | Spacer(Modifier.width(8.dp)) 84 | Text(stringResource(R.string.donate)) 85 | } 86 | } 87 | } 88 | } 89 | } 90 | 91 | @Composable 92 | @PreviewLightDark 93 | private fun AboutDialogPreview() { 94 | ParcelTrackerTheme { AboutDialog(onDismissRequest = {}) } 95 | } 96 | 97 | fun Context.openLinkInBrowser(url: Uri) { 98 | // val browserIntent = Intent(Intent.ACTION_VIEW, url) 99 | // startActivity(browserIntent) 100 | val customTabsIntent = CustomTabsIntent.Builder().apply { setShowTitle(true) }.build() 101 | customTabsIntent.launchUrl(this, url) 102 | } 103 | -------------------------------------------------------------------------------- /app/src/main/java/dev/itsvic/parceltracker/ui/components/LogcatButton.kt: -------------------------------------------------------------------------------- 1 | package dev.itsvic.parceltracker.ui.components 2 | 3 | import android.content.Intent 4 | import androidx.compose.material3.FilledTonalButton 5 | import androidx.compose.material3.Text 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.platform.LocalContext 9 | import androidx.compose.ui.res.stringResource 10 | import dev.itsvic.parceltracker.LogcatDumperActivity 11 | import dev.itsvic.parceltracker.R 12 | 13 | @Composable 14 | fun LogcatButton(modifier: Modifier = Modifier) { 15 | val context = LocalContext.current 16 | 17 | FilledTonalButton( 18 | onClick = { context.startActivity(Intent(context, LogcatDumperActivity::class.java)) }, 19 | modifier = modifier, 20 | ) { 21 | Text(stringResource(R.string.dump_logs_button)) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/src/main/java/dev/itsvic/parceltracker/ui/components/ParcelHistoryItemRow.kt: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | package dev.itsvic.parceltracker.ui.components 3 | 4 | import androidx.compose.foundation.background 5 | import androidx.compose.foundation.layout.Arrangement 6 | import androidx.compose.foundation.layout.Box 7 | import androidx.compose.foundation.layout.Column 8 | import androidx.compose.foundation.layout.Row 9 | import androidx.compose.foundation.layout.fillMaxWidth 10 | import androidx.compose.foundation.text.selection.SelectionContainer 11 | import androidx.compose.material3.MaterialTheme 12 | import androidx.compose.material3.Text 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.text.style.TextAlign 16 | import androidx.compose.ui.tooling.preview.PreviewLightDark 17 | import androidx.compose.ui.unit.dp 18 | import androidx.compose.ui.unit.sp 19 | import dev.itsvic.parceltracker.api.ParcelHistoryItem 20 | import dev.itsvic.parceltracker.ui.theme.ParcelTrackerTheme 21 | import java.time.LocalDateTime 22 | import java.time.format.DateTimeFormatter 23 | import java.time.format.FormatStyle 24 | 25 | @Composable 26 | fun ParcelHistoryItemRow(item: ParcelHistoryItem) { 27 | Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { 28 | SelectionContainer { Text(item.description, color = MaterialTheme.colorScheme.onBackground) } 29 | Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { 30 | Text( 31 | item.time.format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT)), 32 | fontSize = 13.sp, 33 | lineHeight = 19.5f.sp, 34 | color = MaterialTheme.colorScheme.onSurfaceVariant) 35 | Text( 36 | item.location, 37 | fontSize = 13.sp, 38 | lineHeight = 19.5f.sp, 39 | textAlign = TextAlign.End, 40 | color = MaterialTheme.colorScheme.onSurfaceVariant) 41 | } 42 | } 43 | } 44 | 45 | @Composable 46 | @PreviewLightDark 47 | private fun ParcelHistoryItemRowPreview() { 48 | val exampleItem = 49 | ParcelHistoryItem( 50 | "Customs service", 51 | LocalDateTime.of(2024, 12, 22, 9, 38, 48), 52 | "ZCPP\nul. Rodziny Hiszpańskich 8\n00-940 Warszawa") 53 | 54 | ParcelTrackerTheme { 55 | Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.background)) { 56 | ParcelHistoryItemRow(exampleItem) 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /app/src/main/java/dev/itsvic/parceltracker/ui/components/ParcelRow.kt: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | package dev.itsvic.parceltracker.ui.components 3 | 4 | import androidx.compose.foundation.background 5 | import androidx.compose.foundation.clickable 6 | import androidx.compose.foundation.layout.Arrangement 7 | import androidx.compose.foundation.layout.Box 8 | import androidx.compose.foundation.layout.Column 9 | import androidx.compose.foundation.layout.Row 10 | import androidx.compose.foundation.layout.fillMaxWidth 11 | import androidx.compose.foundation.layout.padding 12 | import androidx.compose.foundation.layout.size 13 | import androidx.compose.foundation.shape.CircleShape 14 | import androidx.compose.material3.Icon 15 | import androidx.compose.material3.MaterialTheme 16 | import androidx.compose.material3.Text 17 | import androidx.compose.runtime.Composable 18 | import androidx.compose.ui.Alignment 19 | import androidx.compose.ui.Modifier 20 | import androidx.compose.ui.draw.clip 21 | import androidx.compose.ui.res.painterResource 22 | import androidx.compose.ui.res.stringResource 23 | import androidx.compose.ui.tooling.preview.PreviewLightDark 24 | import androidx.compose.ui.unit.dp 25 | import androidx.compose.ui.unit.sp 26 | import dev.itsvic.parceltracker.R 27 | import dev.itsvic.parceltracker.api.Service 28 | import dev.itsvic.parceltracker.api.Status 29 | import dev.itsvic.parceltracker.api.getDeliveryServiceName 30 | import dev.itsvic.parceltracker.db.Parcel 31 | import dev.itsvic.parceltracker.ui.theme.ParcelTrackerTheme 32 | 33 | @Composable 34 | fun ParcelRow(parcel: Parcel, status: Status?, onClick: () -> Unit) { 35 | Row( 36 | modifier = Modifier.clickable(onClick = onClick).fillMaxWidth().padding(16.dp, 12.dp), 37 | horizontalArrangement = Arrangement.spacedBy(16.dp), 38 | verticalAlignment = Alignment.CenterVertically) { 39 | if (status != null) 40 | Box( 41 | modifier = 42 | Modifier.size(40.dp) 43 | .clip(CircleShape) 44 | .background(MaterialTheme.colorScheme.primaryContainer), 45 | contentAlignment = Alignment.Center) { 46 | Icon( 47 | painterResource( 48 | when (status) { 49 | Status.Preadvice -> R.drawable.outline_other_admission_24 50 | Status.InTransit -> R.drawable.outline_local_shipping_24 51 | Status.InWarehouse -> R.drawable.outline_warehouse_24 52 | Status.Customs -> R.drawable.outline_search_24 53 | Status.OutForDelivery -> R.drawable.outline_local_shipping_24 54 | Status.DeliveryFailure -> R.drawable.outline_error_24 55 | Status.AwaitingPickup -> R.drawable.outline_pin_drop_24 56 | Status.Delivered, 57 | Status.PickedUp -> R.drawable.outline_check_24 58 | else -> R.drawable.outline_question_mark_24 59 | }), 60 | stringResource(status.nameResource), 61 | tint = MaterialTheme.colorScheme.primary) 62 | } 63 | 64 | Column { 65 | Text(parcel.humanName, color = MaterialTheme.colorScheme.onBackground) 66 | 67 | Text( 68 | "${stringResource(getDeliveryServiceName(parcel.service)!!)}: ${parcel.parcelId}", 69 | fontSize = 12.sp, 70 | color = MaterialTheme.colorScheme.onSurfaceVariant) 71 | } 72 | } 73 | } 74 | 75 | @Composable 76 | @PreviewLightDark 77 | fun ParcelRowPreview() { 78 | ParcelTrackerTheme { 79 | Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.background)) { 80 | ParcelRow( 81 | Parcel(0, "My precious package", "EXMPL0001", null, Service.EXAMPLE), 82 | status = Status.InTransit, 83 | onClick = {}, 84 | ) 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /app/src/main/java/dev/itsvic/parceltracker/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package dev.itsvic.parceltracker.ui.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val primaryLight = Color(0xFF8D4D2D) 6 | val onPrimaryLight = Color(0xFFFFFFFF) 7 | val primaryContainerLight = Color(0xFFFFDBCC) 8 | val onPrimaryContainerLight = Color(0xFF351000) 9 | val secondaryLight = Color(0xFF765749) 10 | val onSecondaryLight = Color(0xFFFFFFFF) 11 | val secondaryContainerLight = Color(0xFFFFDBCC) 12 | val onSecondaryContainerLight = Color(0xFF2C160B) 13 | val tertiaryLight = Color(0xFF655F31) 14 | val onTertiaryLight = Color(0xFFFFFFFF) 15 | val tertiaryContainerLight = Color(0xFFEDE4A9) 16 | val onTertiaryContainerLight = Color(0xFF1F1C00) 17 | val errorLight = Color(0xFFBA1A1A) 18 | val onErrorLight = Color(0xFFFFFFFF) 19 | val errorContainerLight = Color(0xFFFFDAD6) 20 | val onErrorContainerLight = Color(0xFF410002) 21 | val backgroundLight = Color(0xFFFFF8F6) 22 | val onBackgroundLight = Color(0xFF221A16) 23 | val surfaceLight = Color(0xFFFFF8F6) 24 | val onSurfaceLight = Color(0xFF221A16) 25 | val surfaceVariantLight = Color(0xFFF4DED5) 26 | val onSurfaceVariantLight = Color(0xFF52443D) 27 | val outlineLight = Color(0xFF85736C) 28 | val outlineVariantLight = Color(0xFFD7C2B9) 29 | val scrimLight = Color(0xFF000000) 30 | val inverseSurfaceLight = Color(0xFF382E2A) 31 | val inverseOnSurfaceLight = Color(0xFFFFEDE6) 32 | val inversePrimaryLight = Color(0xFFFFB693) 33 | val surfaceDimLight = Color(0xFFE8D7D0) 34 | val surfaceBrightLight = Color(0xFFFFF8F6) 35 | val surfaceContainerLowestLight = Color(0xFFFFFFFF) 36 | val surfaceContainerLowLight = Color(0xFFFFF1EB) 37 | val surfaceContainerLight = Color(0xFFFCEAE3) 38 | val surfaceContainerHighLight = Color(0xFFF6E5DE) 39 | val surfaceContainerHighestLight = Color(0xFFF0DFD8) 40 | 41 | val primaryDark = Color(0xFFFFB693) 42 | val onPrimaryDark = Color(0xFF542104) 43 | val primaryContainerDark = Color(0xFF703718) 44 | val onPrimaryContainerDark = Color(0xFFFFDBCC) 45 | val secondaryDark = Color(0xFFE6BEAC) 46 | val onSecondaryDark = Color(0xFF432A1E) 47 | val secondaryContainerDark = Color(0xFF5C4033) 48 | val onSecondaryContainerDark = Color(0xFFFFDBCC) 49 | val tertiaryDark = Color(0xFFD0C890) 50 | val onTertiaryDark = Color(0xFF363107) 51 | val tertiaryContainerDark = Color(0xFF4D481C) 52 | val onTertiaryContainerDark = Color(0xFFEDE4A9) 53 | val errorDark = Color(0xFFFFB4AB) 54 | val onErrorDark = Color(0xFF690005) 55 | val errorContainerDark = Color(0xFF93000A) 56 | val onErrorContainerDark = Color(0xFFFFDAD6) 57 | val backgroundDark = Color(0xFF1A120E) 58 | val onBackgroundDark = Color(0xFFF0DFD8) 59 | val surfaceDark = Color(0xFF1A120E) 60 | val onSurfaceDark = Color(0xFFF0DFD8) 61 | val surfaceVariantDark = Color(0xFF52443D) 62 | val onSurfaceVariantDark = Color(0xFFD7C2B9) 63 | val outlineDark = Color(0xFFA08D85) 64 | val outlineVariantDark = Color(0xFF52443D) 65 | val scrimDark = Color(0xFF000000) 66 | val inverseSurfaceDark = Color(0xFFF0DFD8) 67 | val inverseOnSurfaceDark = Color(0xFF382E2A) 68 | val inversePrimaryDark = Color(0xFF8D4D2D) 69 | val surfaceDimDark = Color(0xFF1A120E) 70 | val surfaceBrightDark = Color(0xFF423732) 71 | val surfaceContainerLowestDark = Color(0xFF140C09) 72 | val surfaceContainerLowDark = Color(0xFF221A16) 73 | val surfaceContainerDark = Color(0xFF271E1A) 74 | val surfaceContainerHighDark = Color(0xFF322824) 75 | val surfaceContainerHighestDark = Color(0xFF3D332E) 76 | -------------------------------------------------------------------------------- /app/src/main/java/dev/itsvic/parceltracker/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package dev.itsvic.parceltracker.ui.theme 2 | 3 | import android.os.Build 4 | import androidx.compose.foundation.isSystemInDarkTheme 5 | import androidx.compose.material3.MaterialTheme 6 | import androidx.compose.material3.darkColorScheme 7 | import androidx.compose.material3.dynamicDarkColorScheme 8 | import androidx.compose.material3.dynamicLightColorScheme 9 | import androidx.compose.material3.lightColorScheme 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.platform.LocalContext 12 | 13 | private val lightScheme = 14 | lightColorScheme( 15 | primary = primaryLight, 16 | onPrimary = onPrimaryLight, 17 | primaryContainer = primaryContainerLight, 18 | onPrimaryContainer = onPrimaryContainerLight, 19 | secondary = secondaryLight, 20 | onSecondary = onSecondaryLight, 21 | secondaryContainer = secondaryContainerLight, 22 | onSecondaryContainer = onSecondaryContainerLight, 23 | tertiary = tertiaryLight, 24 | onTertiary = onTertiaryLight, 25 | tertiaryContainer = tertiaryContainerLight, 26 | onTertiaryContainer = onTertiaryContainerLight, 27 | error = errorLight, 28 | onError = onErrorLight, 29 | errorContainer = errorContainerLight, 30 | onErrorContainer = onErrorContainerLight, 31 | background = backgroundLight, 32 | onBackground = onBackgroundLight, 33 | surface = surfaceLight, 34 | onSurface = onSurfaceLight, 35 | surfaceVariant = surfaceVariantLight, 36 | onSurfaceVariant = onSurfaceVariantLight, 37 | outline = outlineLight, 38 | outlineVariant = outlineVariantLight, 39 | scrim = scrimLight, 40 | inverseSurface = inverseSurfaceLight, 41 | inverseOnSurface = inverseOnSurfaceLight, 42 | inversePrimary = inversePrimaryLight, 43 | surfaceDim = surfaceDimLight, 44 | surfaceBright = surfaceBrightLight, 45 | surfaceContainerLowest = surfaceContainerLowestLight, 46 | surfaceContainerLow = surfaceContainerLowLight, 47 | surfaceContainer = surfaceContainerLight, 48 | surfaceContainerHigh = surfaceContainerHighLight, 49 | surfaceContainerHighest = surfaceContainerHighestLight, 50 | ) 51 | 52 | private val darkScheme = 53 | darkColorScheme( 54 | primary = primaryDark, 55 | onPrimary = onPrimaryDark, 56 | primaryContainer = primaryContainerDark, 57 | onPrimaryContainer = onPrimaryContainerDark, 58 | secondary = secondaryDark, 59 | onSecondary = onSecondaryDark, 60 | secondaryContainer = secondaryContainerDark, 61 | onSecondaryContainer = onSecondaryContainerDark, 62 | tertiary = tertiaryDark, 63 | onTertiary = onTertiaryDark, 64 | tertiaryContainer = tertiaryContainerDark, 65 | onTertiaryContainer = onTertiaryContainerDark, 66 | error = errorDark, 67 | onError = onErrorDark, 68 | errorContainer = errorContainerDark, 69 | onErrorContainer = onErrorContainerDark, 70 | background = backgroundDark, 71 | onBackground = onBackgroundDark, 72 | surface = surfaceDark, 73 | onSurface = onSurfaceDark, 74 | surfaceVariant = surfaceVariantDark, 75 | onSurfaceVariant = onSurfaceVariantDark, 76 | outline = outlineDark, 77 | outlineVariant = outlineVariantDark, 78 | scrim = scrimDark, 79 | inverseSurface = inverseSurfaceDark, 80 | inverseOnSurface = inverseOnSurfaceDark, 81 | inversePrimary = inversePrimaryDark, 82 | surfaceDim = surfaceDimDark, 83 | surfaceBright = surfaceBrightDark, 84 | surfaceContainerLowest = surfaceContainerLowestDark, 85 | surfaceContainerLow = surfaceContainerLowDark, 86 | surfaceContainer = surfaceContainerDark, 87 | surfaceContainerHigh = surfaceContainerHighDark, 88 | surfaceContainerHighest = surfaceContainerHighestDark, 89 | ) 90 | 91 | @Composable 92 | fun ParcelTrackerTheme( 93 | darkTheme: Boolean = isSystemInDarkTheme(), 94 | // Dynamic color is available on Android 12+ 95 | dynamicColor: Boolean = true, 96 | content: @Composable() () -> Unit 97 | ) { 98 | val colorScheme = 99 | when { 100 | dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { 101 | val context = LocalContext.current 102 | if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) 103 | } 104 | 105 | darkTheme -> darkScheme 106 | else -> lightScheme 107 | } 108 | 109 | MaterialTheme(colorScheme = colorScheme, typography = ParcelTrackerTypography, content = content) 110 | } 111 | -------------------------------------------------------------------------------- /app/src/main/java/dev/itsvic/parceltracker/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package dev.itsvic.parceltracker.ui.theme 2 | 3 | import androidx.compose.material3.Typography 4 | 5 | val ParcelTrackerTypography = Typography() 6 | -------------------------------------------------------------------------------- /app/src/main/java/dev/itsvic/parceltracker/ui/theme/Values.kt: -------------------------------------------------------------------------------- 1 | package dev.itsvic.parceltracker.ui.theme 2 | 3 | import androidx.compose.foundation.layout.PaddingValues 4 | import androidx.compose.ui.unit.dp 5 | 6 | val MenuItemContentPadding = PaddingValues(start = 20.dp, end = 24.dp, top = 8.dp, bottom = 8.dp) 7 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/archive.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/github.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_background.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 13 | 16 | 19 | 22 | 24 | 25 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_monochrome.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 12 | 15 | 18 | 21 | 22 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/license_24px.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/outline_check_24.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/outline_error_24.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/outline_local_shipping_24.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/outline_other_admission_24.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/outline_pin_drop_24.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/outline_question_mark_24.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/outline_search_24.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/outline_warehouse_24.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/package_2.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/volunteer_activism_24px.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/resources.properties: -------------------------------------------------------------------------------- 1 | unqualifiedResLocale=en-US 2 | -------------------------------------------------------------------------------- /app/src/main/res/values-cs/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | O aplikaci 3 | Přidat zásilku 4 | Přidat zásilku 5 | Archivovat 6 | Chcete archivovat tuto zásilku? 7 | Pokud archivujete tuto zásilku, bude na zařízení uchována její historie. Nebude ale pravidelně kontrolován její stav. 8 | Oznámení o aktuálním stavu zásilky 9 | Události zásilky 10 | Aktuální stav 11 | Odstranit 12 | Doručovací služba 13 | Vzorový režim 14 | Akce není ve vzorovém režimu povolena 15 | Zobrazí vzorové zásilky pro účely demonstrace. Neovlivňuje uživatelská data. 16 | Darujte 17 | Upravit 18 | Upravit zásilku 19 | Přejít zpět 20 | Název nesmí být prázdný. 21 | Ignorovat 22 | Licence 23 | Další možnosti 24 | Vaše zařízení může být offline, servery služby mohou být offline, nebo jste zadali nesprávné údaje. 25 | V současné době nemáte žádné zásilky. 26 | Tato zásilka zatím neexistuje na serverech doručovací služby. Ujistěte se, že jsou podrobnosti zásilky správné. 27 | Historie zásilky 28 | Název 29 | PSČ 30 | PSČ nesmí být prázdné. 31 | Doručeno 32 | Předpokládaný čas doručení 33 | Váha 34 | Repozitář 35 | Uložit 36 | Belpost 37 | DPD UK 38 | Musíte vybrat službu. 39 | Příklad 40 | Maďarská pošta 41 | Polská pošta 42 | Italská pošta 43 | Sameday Bulharsko 44 | Sameday Maďarsko 45 | Sameday Rumunsko 46 | SPX Thailand 47 | Nedefinováno 48 | Nastavení 49 | Experimentální 50 | Zadejte PSČ 51 | Volitelné. S poštovním směrovacím číslem můžete získat přístup k více informacím, jako je poloha. 52 | Čeká na vyzvednutí 53 | Dorazilo na celnici 54 | Celní odbavení 55 | Doručeno 56 | Nepodařilo se doručit 57 | Na cestě 58 | Dorazilo na depo 59 | Chyba sítě 60 | Žádná data 61 | Doručování 62 | Vyzvednuto 63 | Zatím neodesláno 64 | Neznámý stavový kód 65 | Sledovací ID 66 | Sledovací ID nesmí být prázdné. 67 | Aktualizovat pouze na neměřených sítích 68 | Pokud je povoleno, bude aplikace Parcel zjišťovat aktualizace pouze na neměřených připojeních, jako je vaše domácí Wi-Fi. 69 | 70 | -------------------------------------------------------------------------------- /app/src/main/res/values-hu/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | App névjegye 3 | Csomag hozzáadása 4 | Csomag hozzáadása 5 | Archivál 6 | Biztosan archiválod ezt a csomagot? 7 | Ha archiválod, akkor a csomag előzmény az eszközön kerül eltárolásra és frissítés sem történik. 8 | Értesítés(ek) a csomag jelenlegi állapotáról 9 | Csomag események 10 | Jelenlegi állapot 11 | Törlés 12 | Futárszolgálat 13 | Teszt mód 14 | A művelet nem engedélyezett teszt módban 15 | Minta csomagokat jelenít meg az applikációban. Nem érinti a már meglévő adatokat. 16 | Támogatás 17 | Szerkesztés 18 | Csomag szerkesztése 19 | Vissza 20 | A név nem lehet üres. 21 | Figyelmen kívül hagy 22 | Licenc 23 | További lehetőségek 24 | Az eszköz offline állapotban van, a futárszolgálat szerverei elérhetetlenek, vagy a megadott adatok érvénytelenek 25 | Jelenleg nincsenek csomagjaid. 26 | Ez a csomag még nem áll rendelkezésre a futárszolgálat szerverein. Kérjük, ellenőrizze a csomag adatait. 27 | Előzmény 28 | Név 29 | Irányítószám 30 | Az irányítószám nem lehet üres. 31 | Kiszállítva ekkor 32 | Becsült érkezés 33 | Súly 34 | Forráskód 35 | Mentés 36 | Belposta 37 | DPD Egyesült Királyság 38 | Ki kell választani egy futárszolgálatot. 39 | Minta Posta 40 | GLS Magyarország 41 | Magyar Posta 42 | Lengyel Posta 43 | Olasz Posta 44 | Sameday Bulgária 45 | Sameday Magyarország 46 | Sameday Románia 47 | SPX Thaiföld 48 | Meghatározatlan 49 | Beállítások 50 | Kísérleti funkció 51 | Irányítószám megadása 52 | Opcionális. További információ jelenhet meg az irányítószám megadásával, például a címzett címe. 53 | Átvételre vár 54 | Vámkezelés alatt 55 | Vámkezelés befejezve 56 | Kiszállítva 57 | Sikertelen kiszállítás 58 | Úton 59 | Megérkezett a depóba 60 | Hálózati hiba 61 | Nincs adat 62 | Kiszállítás alatt 63 | Átvéve 64 | Kiszállítva 65 | Ismeretlen állapotkód 66 | Csomagszám 67 | A csomagszám nem lehet üres. 68 | Csak korlátlan hálózaton való frissítés 69 | A Parcel csak akkor fog frissítéseket keresni automatikusan, ha nem forgalmidíjas hálózatra van csatlakozva a készülék, pl. otthoni Wi-Fi. 70 | 71 | -------------------------------------------------------------------------------- /app/src/main/res/values-ja/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | アプリについて 3 | 荷物を追加 4 | 荷物を追加 5 | アーカイブ 6 | この荷物をアーカイブしますか? 7 | この荷物をアーカイブにすると履歴はデバイスに保存されます。定期的な更新の確認は行われません。 8 | 荷物の状況に関する通知 9 | 荷物のイベント 10 | 現在のステータス 11 | 削除 12 | 配送サービス 13 | デモモード 14 | デモモードでは許可されていない操作です 15 | デモ用のサンプルとなる荷物を表示します。ユーザーデータには影響しません。 16 | DHL はユーザーに API キーの提供をお願いしています。<a href=\"https://developer.dhl.com\">DHL の API デベロッパーポータル</a>から「Shipment Tracking - Unified API」にサインアップすることで、無料で API キーを取得できます。 17 | 寄付 18 | ログをファイルにダンプ 19 | 編集 20 | 荷物を編集 21 | この配送サービスには API キーが必要ですが、提供されていません。詳細はアプリの設定をご確認ください。 22 | 戻る 23 | 名前は空白にできません。 24 | 無視 25 | ライセンス 26 | その他のオプション 27 | デバイスがオフラインか、サービスのサーバーがオフラインまたは入力した情報が正しくない可能性があります。 28 | 現在、荷物はありません。 29 | この荷物は配送サービスのサーバーにまだ存在しません。荷物の詳細が正しいことを確認してください。 30 | 荷物の履歴 31 | 名前 32 | 郵便番号 33 | 郵便番号は空白にできません。 34 | 配送先 35 | ETA 36 | 重さ 37 | リポジトリ 38 | 保存 39 | ベルポシュタ 40 | DPD(イギリス) 41 | サービスを選択する必要があります。 42 | サンプルの配送 43 | GLS(ハンガリー) 44 | ハンガリー郵便 45 | ノヴァポシュタア 46 | ポーランド郵便 47 | イタリヤ郵便 48 | Sameday(ブルガリア) 49 | Sameday(ハンガリー) 50 | Sameday(ルーマニア) 51 | SPX(タイ) 52 | ウクルポシュタ 53 | 未定義 54 | 設定 55 | API キー 56 | 試験的 57 | 郵便番号を指定してください 58 | 任意の設定です。郵便番号を入力すると、場所などの追加情報にアクセスできる場合があります。 59 | ピックアップ待ち 60 | 税関に到着 61 | 通関済み 62 | 配達済み 63 | 配達に失敗 64 | 輸送中 65 | 倉庫に到着 66 | ネットワーク障害 67 | データなし 68 | 配達中 69 | ピックアップ済み 70 | 送信されていません 71 | 不明なステータスコード 72 | 追跡 ID 73 | 追跡 ID は空白にできません。 74 | 定額制ネットワークでのみ更新する 75 | 有効化すると Parcel は自宅の Wi-Fi などの定額制接続時でのみ更新を検索します。 76 | 77 | -------------------------------------------------------------------------------- /app/src/main/res/values-th/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | เกี่ยวกับแอปพลิเคชัน 3 | เพิ่มพัสดุ 4 | เพิ่มพัสดุ 5 | การแจ้งเตือนเกี่ยวกับสถานะของพัสดุ 6 | สถานะพัสดุ 7 | สถานะปัจจุบัน 8 | ลบ 9 | บริการส่งพัสดุ 10 | โหมดสาธิต 11 | ไม่สามารถใช้งานในโหมดสาธิตได้ 12 | แสดงพัสดุตัวอย่างเพื่อการสาธิตการใช้งาน ไม่มีผลต่อข้อมูลที่บันทึกไว้ 13 | บริจาค 14 | แก้ไข 15 | แก้ไขพัสดุ 16 | กลับ 17 | ชื่อพัสดุไม่สามารถเว้นว่างได้ 18 | ลิขสิทธิ์ 19 | ตัวเลือกเพิ่มเติม 20 | อาจจะเป็นไปได้ว่า อุปกรณ์ของคุณไม่มีสัญญาณในขณะนี้ หรือเกิดปัญหาที่เซิฟเวอร์ฝั่งผู้ให้บริการขนส่ง หรือคุณใส่รายละเอียดของพัสดุไม่ถูกต้อง 21 | ไม่มีพัสดุอยู่ 22 | พัสดุนี้ยังไม่อยู่ในระบบของเซิฟเวอร์ฝั่งผู้ให้บริการขนส่ง โปรดตรวจสอบรายละเอียดของพัสดุใหม่ 23 | ประวัติพัสดุ 24 | ชื่อพัสดุ 25 | รหัสไปรษณีย์ 26 | รหัสพัสดุไม่สามารถเว้นว่างได้ 27 | นำส่งพัสดุแล้วเมื่อ 28 | จะถีงประมาณ 29 | น้ำหนักพัสดุ 30 | ดูโค้ด 31 | บันทึก 32 | เบลโปชตา 33 | DPD สหราชอาณาจักร 34 | ต้องเลือกบริการส่งพัสดุ 35 | ไปรษณีย์ กขคง 36 | ไปรษณีย์ฮังการี 37 | ไปรษณีย์โปแลนด์ 38 | ไปรษณีย์อิตาลี 39 | Sameday บัลแกเรีย 40 | Sameday ฮังการี 41 | Sameday โรมาเนีย 42 | SPX ประเทศไทย 43 | ไม่ได้ระบุ 44 | การตั้งค่า 45 | กำลังทดสอบ 46 | โปรดระบุรหัสไปรษณีย์ 47 | เลือกที่จะไม่ใส่ได้ แต่คุณอาจจะเห็นข้อมูลเพิ่มเติม เช่น ตำแหน่ง เมื่อคุณระบุรหัสไปรษณีย์ 48 | กำลังรอบริษัทขนส่งรับพัสดุ 49 | พัสดุถึงศุลกากร 50 | ผ่านพิธีการศุลกากรแล้ว 51 | การนำส่งพัสดุสำเร็จ 52 | การนำส่งพัสดุล้มเหลว 53 | พัสดุอยู่ระหว่างการขนส่ง 54 | พัสดุถึงคลังสินค้า 55 | เครือข่ายล้มเหลว 56 | ไม่มีข้อมูล 57 | กำลังนำส่งพัสดุ 58 | บริษัทขนส่งรับพัสดุสำเร็จ 59 | พัสดุยังไม่ถูกจัดส่ง 60 | รหัสสถานะที่ไม่รู้จัก 61 | รหัสพัสดุ 62 | รหัสพัสดุไม่สามารถเว้นว่างได้ 63 | อัปเดตเมื่อเชื่อมต่อเครือข่ายที่ไม่จำกัดการใช้งานเท่านั้น 64 | เมื่อเปิด Parcel จะตรวจสอบสถานะของพัสดุเมื่อเชื่อมต่อเครือข่ายที่ไม่จำกัดการใช้งานเท่านั้น เช่น Wi-Fi บ้าน 65 | 66 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 |