diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 753708009e..eb51f1cc15 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -40,6 +40,7 @@ env: desktop desktop-package foundations + mobile qms-desktop-package dev models diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml new file mode 100644 index 0000000000..1866c7b915 --- /dev/null +++ b/.github/workflows/mobile.yml @@ -0,0 +1,95 @@ +# Builds the Capacitor mobile app and publishes test artifacts (Android debug APK). +# iOS is intentionally not built here yet: it needs a macOS runner and Apple signing +# credentials to produce an installable artifact. + +name: Mobile + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + push: + branches: [develop, main, mobile-app, 'claude/**'] + paths: + - 'mobile/**' + - '.github/workflows/mobile.yml' + pull_request: + paths: + - 'mobile/**' + - '.github/workflows/mobile.yml' + + # Manual run: build an APK for testing from any branch + workflow_dispatch: + inputs: + config: + description: 'Config file from dev/prod/public to bundle into the app as config.json' + required: false + default: 'config-mobile-huly.json' + +jobs: + android: + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + filter: tree:0 + + - uses: actions/setup-node@v6 + with: + node-version-file: '.nvmrc' + + - uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '21' + + - name: Cache node modules + uses: actions/cache@v5 + env: + cache-name: cache-node-platform + with: + path: | + common/temp + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/pnpm-lock.yaml') }} + + - name: Installing... + run: node common/scripts/install-run-rush.js install + + - name: Building web app bundle... + run: node common/scripts/install-run-rush.js package --to @hcengineering/prod + + - name: Install mobile dependencies + working-directory: mobile + run: npm ci + + - name: Copy web assets and sync native project + working-directory: mobile + env: + MOBILE_CONFIG: ${{ github.event.inputs.config || 'config-mobile-huly.json' }} + run: | + npm run build:web + npx cap sync android + + - name: Build Android debug APK + working-directory: mobile/android + env: + MOBILE_VERSION_CODE: ${{ github.run_number }} + MOBILE_VERSION_NAME: 1.0.${{ github.run_number }} + run: | + chmod +x ./gradlew + ./gradlew assembleDebug + + - name: Upload APK + uses: actions/upload-artifact@v6 + with: + name: huly-android-debug-apk + path: mobile/android/app/build/outputs/apk/debug/app-debug.apk + retention-days: 14 diff --git a/.gitignore b/.gitignore index 1ec48e0f2d..3fd8a0b46f 100644 --- a/.gitignore +++ b/.gitignore @@ -87,6 +87,7 @@ tsconfig.tsbuildinfo ingest-attachment-*.zip tsdoc-metadata.json pods/front/dist +mobile/www *.cpuprofile *.pyc metrics.txt diff --git a/README.md b/README.md index ac914af7ce..22454bd799 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ For detailed information about the platform architecture, services, and their in - [Installation](#installation) - [Build and run](#build-and-run) - [Run in development mode](#run-in-development-mode) + - [Mobile (iOS and Android)](#mobile-ios-and-android) - [Update project structure and database](#update-project-structure-and-database) - [Troubleshooting](#troubleshooting) - [Build \& Watch](#build--watch) @@ -253,6 +254,32 @@ Then go to Select "Sign up" on the right panel and click the "Sign up with password" link at the bottom. Enter the new user's credentials, then proceed to create a workspace for them. +## Mobile (iOS and Android) + +The Platform includes a Capacitor-based mobile app that wraps the same web frontend. Prerequisites: Node.js, Rush, and for native builds **Xcode** (macOS) and/or **Android Studio**. + +1. Build the web app once (from the repo root): + + ```bash + rush build --to @hcengineering/prod + ``` + +2. Copy web assets into the mobile project and sync native projects: + + ```bash + cd mobile + npm run sync + ``` + +3. Open and run the app: + + ```bash + npm run open:ios # Opens Xcode (macOS only) + npm run open:android # Opens Android Studio + ``` + +For development, you can point the app at the webpack dev server by keeping `server.url` in `mobile/capacitor.config.ts` (e.g. `http://localhost:8080`). For production builds, remove or override the `server` setting so the app loads bundled content from `www/`. See [mobile/README.md](mobile/README.md) for details. + ## Update project structure and database If the project's structure is updated, it may be necessary to relink and rebuild the projects. diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 70ba810550..acda2ce988 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -5626,6 +5626,31 @@ importers: specifier: ^29.1.1 version: 29.4.5(@babel/core@7.28.5)(@jest/transform@29.7.0)(@jest/types@30.2.0)(babel-jest@29.7.0(@babel/core@7.28.5))(esbuild@0.25.12)(jest-util@30.2.0)(jest@29.7.0(@types/node@22.19.0)(ts-node@10.9.2(@types/node@22.19.0)(typescript@5.9.3)))(typescript@5.9.3) + ../../mobile: + dependencies: + '@capacitor/network': + specifier: ^7.0.0 + version: 7.0.4(@capacitor/core@7.4.5) + '@capacitor/splash-screen': + specifier: ^7.0.0 + version: 7.0.5(@capacitor/core@7.4.5) + devDependencies: + '@capacitor/android': + specifier: ^7.0.0 + version: 7.4.5(@capacitor/core@7.4.5) + '@capacitor/cli': + specifier: ^7.0.0 + version: 7.4.5 + '@capacitor/core': + specifier: ^7.0.0 + version: 7.4.5 + '@capacitor/ios': + specifier: ^7.0.0 + version: 7.4.5(@capacitor/core@7.4.5) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + ../../models/achievement: dependencies: '@hcengineering/achievement': @@ -42403,6 +42428,34 @@ packages: '@bufbuild/protobuf@1.10.1': resolution: {integrity: sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==} + '@capacitor/android@7.4.5': + resolution: {integrity: sha512-G76C+PGIFkG+hxj6JLlY003txRaGZwsXBPo4b4xiZMZaD6DG++sDCoumYl/5xL40dL2b8P5tb3CKf/khWyL2Gw==} + peerDependencies: + '@capacitor/core': ^7.4.0 + + '@capacitor/cli@7.4.5': + resolution: {integrity: sha512-ApKxpzcWTqmyTD+WxBvL7UJt+x0xr9Vqa+S4Iqbl/R2kC0tf1ay22wE9pOrdWXbTqFbNhBuUuRvTvZ6Edp7QzQ==} + engines: {node: '>=20.0.0'} + hasBin: true + + '@capacitor/core@7.4.5': + resolution: {integrity: sha512-JnUywi0WRbqm4QYoplNsMQ7KAVuyrQFha0dOH9YDVCyrVHUvPcvrnK+9j5elUMgy+pOh3ve+pk6BVoySr91c+A==} + + '@capacitor/ios@7.4.5': + resolution: {integrity: sha512-i9cfo15Vn0GvFJ0Pm2zPhxQrK3NW5ISrf7bdzmKWUM5TUaCnWkCO4Zxsgjm8/VMXwt/gabA/di1O1c9pC3enCA==} + peerDependencies: + '@capacitor/core': ^7.4.0 + + '@capacitor/network@7.0.4': + resolution: {integrity: sha512-fNhN3968AQWMnzasPJ0B8e+nyk7+R6V9PWIBQWSQRHrKndDgWRHpxkjEzJPgnJr3EjnKLjbwO+UYy+EwBlGs3A==} + peerDependencies: + '@capacitor/core': '>=7.0.0' + + '@capacitor/splash-screen@7.0.5': + resolution: {integrity: sha512-bPG2SamFL7VT5I3XsgsGgJhkiyxq3OXCOVKEDupSieVdtEiG7j2vLbSD0xL+91zteF/qLuxsjbugGy5LD8mS2A==} + peerDependencies: + '@capacitor/core': '>=7.0.0' + '@chevrotain/cst-dts-gen@11.0.3': resolution: {integrity: sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==} @@ -42895,6 +42948,38 @@ packages: cpu: [x64] os: [win32] + '@ionic/cli-framework-output@2.2.8': + resolution: {integrity: sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g==} + engines: {node: '>=16.0.0'} + + '@ionic/utils-array@2.1.6': + resolution: {integrity: sha512-0JZ1Zkp3wURnv8oq6Qt7fMPo5MpjbLoUoa9Bu2Q4PJuSDWM8H8gwF3dQO7VTeUj3/0o1IB1wGkFWZZYgUXZMUg==} + engines: {node: '>=16.0.0'} + + '@ionic/utils-fs@3.1.7': + resolution: {integrity: sha512-2EknRvMVfhnyhL1VhFkSLa5gOcycK91VnjfrTB0kbqkTFCOXyXgVLI5whzq7SLrgD9t1aqos3lMMQyVzaQ5gVA==} + engines: {node: '>=16.0.0'} + + '@ionic/utils-object@2.1.6': + resolution: {integrity: sha512-vCl7sl6JjBHFw99CuAqHljYJpcE88YaH2ZW4ELiC/Zwxl5tiwn4kbdP/gxi2OT3MQb1vOtgAmSNRtusvgxI8ww==} + engines: {node: '>=16.0.0'} + + '@ionic/utils-process@2.1.12': + resolution: {integrity: sha512-Jqkgyq7zBs/v/J3YvKtQQiIcxfJyplPgECMWgdO0E1fKrrH8EF0QGHNJ9mJCn6PYe2UtHNS8JJf5G21e09DfYg==} + engines: {node: '>=16.0.0'} + + '@ionic/utils-stream@3.1.7': + resolution: {integrity: sha512-eSELBE7NWNFIHTbTC2jiMvh1ABKGIpGdUIvARsNPMNQhxJB3wpwdiVnoBoTYp+5a6UUIww4Kpg7v6S7iTctH1w==} + engines: {node: '>=16.0.0'} + + '@ionic/utils-subprocess@3.0.1': + resolution: {integrity: sha512-cT4te3AQQPeIM9WCwIg8ohroJ8TjsYaMb2G4ZEgv9YzeDqHZ4JpeIKqG2SoaA3GmVQ3sOfhPM6Ox9sxphV/d1A==} + engines: {node: '>=16.0.0'} + + '@ionic/utils-terminal@2.3.5': + resolution: {integrity: sha512-3cKScz9Jx2/Pr9ijj1OzGlBDfcmx7OMVBt4+P1uRR0SSW4cm1/y3Mo4OY3lfkuaYifMNBW8Wz6lQHbs1bihr7A==} + engines: {node: '>=16.0.0'} + '@isaacs/balanced-match@4.0.1': resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} engines: {node: 20 || >=22} @@ -44969,6 +45054,9 @@ packages: '@types/express@4.17.25': resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==} + '@types/fs-extra@8.1.5': + resolution: {integrity: sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ==} + '@types/fs-extra@9.0.13': resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==} @@ -45209,6 +45297,9 @@ packages: '@types/serve-static@1.15.10': resolution: {integrity: sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==} + '@types/slice-ansi@4.0.0': + resolution: {integrity: sha512-+OpjSaq85gvlZAYINyzKpLeiFkSC4EsC6IIiT6v6TLSU5k5U83fHGj9Lel8oKEXM0HqgrMVCjXPDPVICtxF7EQ==} + '@types/snappyjs@0.7.1': resolution: {integrity: sha512-OxjzJ6cQZstysMh6PEwZWmK9qlKZyezHJKOkcUkZDooSFuog2votUEKkxMaTq51UQF3cJkXKQ+XGlj4FSl8JQQ==} @@ -45907,6 +45998,10 @@ packages: bowser@2.12.1: resolution: {integrity: sha512-z4rE2Gxh7tvshQ4hluIT7XcFrgLIQaw9X3A+kTTRdovCz5PMukm/0QC/BKSYPj3omF5Qfypn9O/c5kgpmvYUCw==} + bplist-parser@0.3.2: + resolution: {integrity: sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==} + engines: {node: '>= 5.10.0'} + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -46262,6 +46357,10 @@ packages: resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} engines: {node: '>=14'} + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + commander@14.0.2: resolution: {integrity: sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==} engines: {node: '>=20'} @@ -47099,6 +47198,10 @@ packages: engines: {node: '>= 12.20.55'} hasBin: true + elementtree@0.1.7: + resolution: {integrity: sha512-wkgGT6kugeQk/P6VZ/f4T+4HB41BVgNBq5CDIZVbQ02nvTVqAiVTbskxxu3eA/X96lMlfYOwnLQpN2v5E1zDEg==} + engines: {node: '>= 0.4.0'} + email-addresses@5.0.0: resolution: {integrity: sha512-4OIPYlA6JXqtVn8zpHpGiI7vE6EQOAg16aGnDMIAlZVinnoZ8208tW1hAbjWydgN/4PLTT9q+O1K6AH/vALJGw==} @@ -47889,6 +47992,10 @@ packages: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} hasBin: true + glob@13.0.0: + resolution: {integrity: sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==} + engines: {node: 20 || >=22} + glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Glob versions prior to v9 are no longer supported @@ -48272,6 +48379,10 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ini@4.1.3: + resolution: {integrity: sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + install@0.13.0: resolution: {integrity: sha512-zDml/jzr2PKU9I8J/xyZBQn8rPCAY//UOYNmR01XwNwyfhEWObo2SWfSl1+0tm1u6PhxLwDnfsT/6jB7OUxqFA==} engines: {node: '>= 0.10'} @@ -49528,6 +49639,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + native-run@2.0.3: + resolution: {integrity: sha512-U1PllBuzW5d1gfan+88L+Hky2eZx+9gv3Pf6rNBxKbORxi7boHzqiA6QFGSnqMem4j0A9tZ08NMIs5+0m/VS1Q==} + engines: {node: '>=16.0.0'} + hasBin: true + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -50002,6 +50118,10 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} + path-scurry@2.0.1: + resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==} + engines: {node: 20 || >=22} + path-to-regexp@0.1.12: resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} @@ -50651,6 +50771,11 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true + rimraf@6.1.2: + resolution: {integrity: sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g==} + engines: {node: 20 || >=22} + hasBin: true + roarr@2.15.4: resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} engines: {node: '>=8.0'} @@ -50750,6 +50875,9 @@ packages: engines: {node: '>=14.0.0'} hasBin: true + sax@1.1.4: + resolution: {integrity: sha512-5f3k2PbGGp+YtKJjOItpg3P99IMD84E4HOvcfleTb5joCHNXYLsR9yWFPOYGgaeMPDubQILTCMdsFb2OMeOjtg==} + sax@1.4.3: resolution: {integrity: sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==} @@ -50927,6 +51055,10 @@ packages: resolution: {integrity: sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==} engines: {node: '>=8'} + slice-ansi@4.0.0: + resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} + engines: {node: '>=10'} + slice-ansi@5.0.0: resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==} engines: {node: '>=12'} @@ -51022,6 +51154,10 @@ packages: resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==} engines: {node: '>=6'} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} @@ -51337,6 +51473,7 @@ packages: tar@6.2.1: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me tar@7.5.2: resolution: {integrity: sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==} @@ -51729,6 +51866,10 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} + untildify@4.0.0: + resolution: {integrity: sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==} + engines: {node: '>=8'} + unused-filename@4.0.1: resolution: {integrity: sha512-ZX6U1J04K1FoSUeoX1OicAhw4d0aro2qo+L8RhJkiGTNtBNkd/Fi1Wxoc9HzcVu6HfOzm0si/N15JjxFmD1z6A==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -52997,6 +53138,48 @@ snapshots: '@bufbuild/protobuf@1.10.1': {} + '@capacitor/android@7.4.5(@capacitor/core@7.4.5)': + dependencies: + '@capacitor/core': 7.4.5 + + '@capacitor/cli@7.4.5': + dependencies: + '@ionic/cli-framework-output': 2.2.8 + '@ionic/utils-subprocess': 3.0.1 + '@ionic/utils-terminal': 2.3.5 + commander: 12.1.0 + debug: 4.4.3 + env-paths: 2.2.1 + fs-extra: 11.3.2 + kleur: 4.1.5 + native-run: 2.0.3 + open: 8.4.2 + plist: 3.1.0 + prompts: 2.4.2 + rimraf: 6.1.2 + semver: 7.7.3 + tar: 6.2.1 + tslib: 2.8.1 + xml2js: 0.6.2 + transitivePeerDependencies: + - supports-color + + '@capacitor/core@7.4.5': + dependencies: + tslib: 2.8.1 + + '@capacitor/ios@7.4.5(@capacitor/core@7.4.5)': + dependencies: + '@capacitor/core': 7.4.5 + + '@capacitor/network@7.0.4(@capacitor/core@7.4.5)': + dependencies: + '@capacitor/core': 7.4.5 + + '@capacitor/splash-screen@7.0.5(@capacitor/core@7.4.5)': + dependencies: + '@capacitor/core': 7.4.5 + '@chevrotain/cst-dts-gen@11.0.3': dependencies: '@chevrotain/gast': 11.0.3 @@ -53456,6 +53639,82 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true + '@ionic/cli-framework-output@2.2.8': + dependencies: + '@ionic/utils-terminal': 2.3.5 + debug: 4.4.3 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@ionic/utils-array@2.1.6': + dependencies: + debug: 4.4.3 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@ionic/utils-fs@3.1.7': + dependencies: + '@types/fs-extra': 8.1.5 + debug: 4.4.3 + fs-extra: 9.1.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@ionic/utils-object@2.1.6': + dependencies: + debug: 4.4.3 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@ionic/utils-process@2.1.12': + dependencies: + '@ionic/utils-object': 2.1.6 + '@ionic/utils-terminal': 2.3.5 + debug: 4.4.3 + signal-exit: 3.0.7 + tree-kill: 1.2.2 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@ionic/utils-stream@3.1.7': + dependencies: + debug: 4.4.3 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@ionic/utils-subprocess@3.0.1': + dependencies: + '@ionic/utils-array': 2.1.6 + '@ionic/utils-fs': 3.1.7 + '@ionic/utils-process': 2.1.12 + '@ionic/utils-stream': 3.1.7 + '@ionic/utils-terminal': 2.3.5 + cross-spawn: 7.0.6 + debug: 4.4.3 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@ionic/utils-terminal@2.3.5': + dependencies: + '@types/slice-ansi': 4.0.0 + debug: 4.4.3 + signal-exit: 3.0.7 + slice-ansi: 4.0.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + tslib: 2.8.1 + untildify: 4.0.0 + wrap-ansi: 7.0.0 + transitivePeerDependencies: + - supports-color + '@isaacs/balanced-match@4.0.1': {} '@isaacs/brace-expansion@5.0.0': @@ -56127,6 +56386,10 @@ snapshots: '@types/qs': 6.9.18 '@types/serve-static': 1.15.10 + '@types/fs-extra@8.1.5': + dependencies: + '@types/node': 22.19.0 + '@types/fs-extra@9.0.13': dependencies: '@types/node': 22.19.0 @@ -56427,6 +56690,8 @@ snapshots: '@types/node': 22.19.0 '@types/send': 0.17.6 + '@types/slice-ansi@4.0.0': {} + '@types/snappyjs@0.7.1': {} '@types/sockjs@0.3.36': @@ -57017,8 +57282,7 @@ snapshots: dependencies: tslib: 2.8.1 - astral-regex@2.0.0: - optional: true + astral-regex@2.0.0: {} async-exit-hook@2.0.1: {} @@ -57237,6 +57501,10 @@ snapshots: bowser@2.12.1: {} + bplist-parser@0.3.2: + dependencies: + big-integer: 1.6.52 + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -57679,6 +57947,8 @@ snapshots: commander@10.0.1: {} + commander@12.1.0: {} + commander@14.0.2: {} commander@2.11.0: {} @@ -58594,6 +58864,10 @@ snapshots: transitivePeerDependencies: - supports-color + elementtree@0.1.7: + dependencies: + sax: 1.1.4 + email-addresses@5.0.0: {} emittery@0.13.1: {} @@ -59610,6 +59884,12 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 + glob@13.0.0: + dependencies: + minimatch: 10.1.1 + minipass: 7.1.2 + path-scurry: 2.0.1 + glob@7.2.3: dependencies: fs.realpath: 1.0.0 @@ -60091,6 +60371,8 @@ snapshots: inherits@2.0.4: {} + ini@4.1.3: {} + install@0.13.0: {} internal-slot@1.1.0: @@ -61636,6 +61918,22 @@ snapshots: nanoid@3.3.11: {} + native-run@2.0.3: + dependencies: + '@ionic/utils-fs': 3.1.7 + '@ionic/utils-terminal': 2.3.5 + bplist-parser: 0.3.2 + debug: 4.4.3 + elementtree: 0.1.7 + ini: 4.1.3 + plist: 3.1.0 + split2: 4.2.0 + through2: 4.0.2 + tslib: 2.8.1 + yauzl: 2.10.0 + transitivePeerDependencies: + - supports-color + natural-compare@1.4.0: {} negotiator@0.6.3: {} @@ -62118,6 +62416,11 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.2 + path-scurry@2.0.1: + dependencies: + lru-cache: 11.2.2 + minipass: 7.1.2 + path-to-regexp@0.1.12: {} path-to-regexp@6.3.0: {} @@ -62818,6 +63121,11 @@ snapshots: dependencies: glob: 7.2.3 + rimraf@6.1.2: + dependencies: + glob: 13.0.0 + package-json-from-dist: 1.0.1 + roarr@2.15.4: dependencies: boolean: 3.2.0 @@ -62928,6 +63236,8 @@ snapshots: optionalDependencies: '@parcel/watcher': 2.5.1 + sax@1.1.4: {} + sax@1.4.3: {} saxes@6.0.0: @@ -63157,6 +63467,12 @@ snapshots: is-fullwidth-code-point: 3.0.0 optional: true + slice-ansi@4.0.0: + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + slice-ansi@5.0.0: dependencies: ansi-styles: 6.2.3 @@ -63292,6 +63608,8 @@ snapshots: split-on-first@1.1.0: {} + split2@4.2.0: {} + sprintf-js@1.0.3: {} sprintf-js@1.1.3: @@ -64154,6 +64472,8 @@ snapshots: unpipe@1.0.0: {} + untildify@4.0.0: {} + unused-filename@4.0.1: dependencies: escape-string-regexp: 5.0.0 diff --git a/dev/prod/public/config-mobile-huly.json b/dev/prod/public/config-mobile-huly.json new file mode 100644 index 0000000000..8312b20721 --- /dev/null +++ b/dev/prod/public/config-mobile-huly.json @@ -0,0 +1,22 @@ +{ + "ACCOUNTS_URL": "https://account.huly.app/", + "UPLOAD_URL": "https://huly.app/files", + "FILES_URL": "https://huly.app/files/:workspace/:filename?file=:blobId&workspace=:workspace", + "REKONI_URL": "https://rekoni.huly.app", + "TELEGRAM_URL": "https://telegram.huly.app", + "GMAIL_URL": "https://gmail.huly.app/", + "CALENDAR_URL": "https://calendar.huly.app/", + "COLLABORATOR_URL": "wss://collaborator.huly.app", + "BRANDING_URL": "https://huly.app/huly-branding_v2.json", + "GITHUB_APP": "huly-for-github", + "GITHUB_CLIENTID": "Iv1.e263a087de0910e0", + "INTERCOM_APP_ID": "", + "INTERCOM_API_URL": "", + "GITHUB_URL": "https://github.huly.app", + "LIVEKIT_WS": "", + "LOVE_ENDPOINT": "https://love.huly.app/", + "SIGN_URL": "https://sign.huly.app", + "PRINT_URL": "https://print.huly.app", + "TELEGRAM_BOT_URL": "https://telegram-bot.huly.app", + "STATS_URL": "https://stats.huly.app" +} diff --git a/dev/prod/public/config-mobile.json b/dev/prod/public/config-mobile.json new file mode 100644 index 0000000000..c224ac0f33 --- /dev/null +++ b/dev/prod/public/config-mobile.json @@ -0,0 +1,36 @@ +{ + "ACCOUNTS_URL": "http://localhost:8080/account", + "COLLABORATOR_URL": "ws://huly.local:3078", + "PREVIEW_URL": "http://huly.local:4040", + "FILES_URL": "http://huly.local:4030/blob/:workspace/:blobId/:filename", + "UPLOAD_URL": "http://localhost:8080/files", + "DATALAKE_URL": "http://huly.local:4030", + "TELEGRAM_URL": "http://huly.local:8086", + "GMAIL_URL": "http://huly.local:8093", + "CALENDAR_URL": "http://localhost:8095", + "REKONI_URL": "http://localhost:8080/rekoni", + "GITHUB_APP": "uberflow-dev", + "GITHUB_CLIENTID": "Iv1.43f9cac43bd68617", + "GITHUB_URL": "http://huly.local:3500", + "LAST_NAME_FIRST": "true", + "PRINT_URL": "http://huly.local:4005", + "SIGN_URL": "http://huly.local:4006", + "AI_URL": "http://huly.local:4010", + "BRANDING_URL": "http://localhost:8080/branding.json", + "VERSION": null, + "MODEL_VERSION": null, + "STATS_URL": "http://huly.local:4900", + "PASSWORD_STRICTNESS": "none", + "STREAM_URL": "http://huly.local:1080/recording", + "PAYMENT_URL": "http://huly.local:3040", + "PUBLIC_SCHEDULE_URL": "http://huly.local:8060", + "CALDAV_SERVER_URL": "http://huly.local:9070", + "EXPORT_URL": "http://huly.local:4009", + "COMMUNICATION_API_ENABLED": "true", + "BACKUP_URL": "http://huly.local:4039/api/backup", + "PULSE_URL": "ws://huly.local:8099/ws", + "HULYLAKE_URL": "http://huly.local:8096", + "EXCLUDED_APPLICATIONS_FOR_ANONYMOUS": "[\"chunter\", \"notification\"]", + "ANALYTICS_COLLECTOR_URL": "http://huly.local:4017", + "LINK_PREVIEW_URL": "http://huly.local:4041" +} diff --git a/dev/prod/src/main.ts b/dev/prod/src/main.ts index ea4d431004..6f6c0107f0 100644 --- a/dev/prod/src/main.ts +++ b/dev/prod/src/main.ts @@ -17,6 +17,43 @@ import { createApp } from '@hcengineering/ui' import { configurePlatform } from './platform' -configurePlatform().then(() => { - createApp(document.body) -}) +/** + * Check if running inside a Capacitor native app. + */ +function isCapacitorEnvironment (): boolean { + return ( + typeof window !== 'undefined' && + (window as Window & { Capacitor?: unknown }).Capacitor !== undefined + ) +} + +/** + * Show a basic error UI when the app fails to initialize. + * This is a fallback for when the native shell error screen isn't shown. + */ +function showInitializationError (error: Error): void { + console.error('Failed to initialize platform:', error) + + // In Capacitor, the native shell should handle errors, so we don't need to show UI + if (isCapacitorEnvironment()) { + console.log('Running in Capacitor - native shell should handle error display') + return + } + + // For web browsers, show a simple error message + document.body.innerHTML = ` +
+

Connection Error

+

Unable to connect to the server.
Please check if the server is running and try again.

+ +
+ ` +} + +configurePlatform() + .then(() => { + createApp(document.body) + }) + .catch((error: Error) => { + showInitializationError(error) + }) diff --git a/dev/prod/src/platform.ts b/dev/prod/src/platform.ts index 943b4a1529..c0561d31b2 100644 --- a/dev/prod/src/platform.ts +++ b/dev/prod/src/platform.ts @@ -433,8 +433,19 @@ export async function configurePlatform() { configureI18n() const config: Config = await loadServerConfig(configs[clientType ?? ''] ?? '/config.json') - const branding: BrandingMap = - config.BRANDING_URL !== undefined ? await (await fetch(config.BRANDING_URL, { keepalive: true })).json() : {} + + // Load branding with fallback to empty object if fetch fails + let branding: BrandingMap = {} + if (config.BRANDING_URL !== undefined) { + try { + const brandingResponse = await fetch(config.BRANDING_URL, { keepalive: true }) + if (brandingResponse.ok) { + branding = await brandingResponse.json() + } + } catch (e) { + console.warn('Failed to load branding config:', e) + } + } const myBranding = branding[window.location.host] ?? {} console.log('loading configuration', config) diff --git a/mobile/README.md b/mobile/README.md new file mode 100644 index 0000000000..df7987c19d --- /dev/null +++ b/mobile/README.md @@ -0,0 +1,100 @@ +# Platform Mobile App (Capacitor) + +iOS and Android app that wraps the Platform web frontend using [Capacitor](https://capacitorjs.com/). The same Svelte app built by `dev/prod` is copied into `www/` and loaded by the native shell. + +## Prerequisites + +- Node.js and Rush (see root [README](../README.md)) +- **iOS**: macOS with Xcode +- **Android**: [Android Studio](https://developer.android.com/studio) + +## Quick start + +From the repo root: + +1. Build the web app: `rush build --to @hcengineering/prod` +2. `cd mobile` +3. `npm run sync` (copies web assets to `www/` and runs `cap sync`) +4. `npm run open:ios` or `npm run open:android` to open the native IDE + +## Scripts + +| Script | Description | +|--------|-------------| +| `npm run build:web` | Copy `dev/prod/dist` and `dev/prod/public` into `mobile/www`. Run after building the web app. | +| `npm run sync` | Run `build:web` then `cap sync` (updates native projects with `www` content). | +| `npm run open:ios` | Open the iOS project in Xcode. | +| `npm run open:android` | Open the Android project in Android Studio. | +| `npm run run:ios` | Run on iOS simulator/device. | +| `npm run run:android` | Run on Android emulator/device. | + +## Configuration + +- **capacitor.config.ts**: `appId` is `org.platformcollective.app`; `appName` is `Huly`. Change these if you need a different bundle ID or display name. +- **Local-first load**: The app loads from the bundled `www/` directory (no `server.url`), so it opens without requiring a connection to any server. The login page and shell are served from local resources. +- **Backend URL**: The web app reads `/config.json` from the bundle. The copy script uses `dev/prod/public/config-mobile.json` as `www/config.json` when present (full backend URLs); otherwise it copies `config.json`. Set the `MOBILE_CONFIG` environment variable to pick a different file from `dev/prod/public` (e.g. `MOBILE_CONFIG=config-mobile-huly.json npm run sync` for the huly.app cloud backend — this is what CI uses). On Android emulator, replace `localhost` with `10.0.2.2` in `config-mobile.json` if the app cannot reach the backend. +- **Server unavailable**: When the user tries to use backend features (e.g. login) and the server is unreachable, the app shows a “Server unavailable” message in the login (or relevant) UI. To use the webpack dev server for live reload, temporarily add `server: { url: 'http://localhost:8080', cleartext: true }` to `capacitor.config.ts` and run `npx cap sync`. + +## Native Shell Architecture + +The mobile app implements a native shell that handles error states when the server is unavailable: + +### Error Handling + +- **Android**: `MainActivity.java` includes a custom `WebViewClient` that intercepts WebView load errors (connection refused, timeout, no network) and displays a native error screen with a Retry button. +- **iOS**: `CustomBridgeViewController.swift` implements `WKNavigationDelegate` to intercept navigation errors and show a native error overlay. + +### Network Monitoring + +Both platforms monitor network connectivity: +- When network is lost, the native shell shows an error screen +- When network is restored, the error message updates to indicate retry is available +- The `@capacitor/network` plugin provides JavaScript-level network status if needed + +### Splash Screen + +The `@capacitor/splash-screen` plugin is configured with manual control (`launchAutoHide: false`). The native shell keeps the splash screen visible until the WebView successfully loads, providing a smooth transition. + +### Error Screen + +When the server is unavailable: +1. Native error screen appears with app branding +2. User sees "Connection Error" message with description +3. "Retry" button reloads the WebView +4. On success, error screen hides and web app is displayed + +## Permissions and downloads + +- **Android**: camera, microphone and notification permissions are declared in `AndroidManifest.xml`; Capacitor's built-in `BridgeWebChromeClient` prompts for camera/mic at runtime when the web app calls `getUserMedia` (calls, voice messages, photo capture). Notification permission (Android 13+) is requested once on first launch (`requestNotificationPermissionIfNeeded` in `MainActivity.java`). Attachment downloads from the WebView are routed to the system `DownloadManager` (see `setupDownloadListener`) and land in the public Downloads folder. `blob:` URLs are not supported by DownloadManager and are currently ignored. +- **iOS**: usage descriptions for camera, microphone and photo library are set in `Info.plist` (required — iOS kills the app on access without them). Capacitor's `WebViewDelegationHandler` auto-grants WKWebView media-capture requests; the one-time system permission dialogs are driven by the usage descriptions. +- **Known gap**: OAuth logins (GitHub/Google) do not complete inside the WebView — the provider redirects back to the web origin, not the app. Email/password login works. Fixing this requires opening OAuth in the system browser plus a deep-link return (planned). + +## Icons and splash + +App and splash icons use the Huly branding from [dev/prod/public/huly/](../dev/prod/public/huly/) (same source as the web app: `icon-1024.png`, etc.). To refresh mobile icons after updating Huly assets, copy `dev/prod/public/huly/icon-1024.png` to: + +- **Android**: `android/app/src/main/res/mipmap-*/ic_launcher.png`, `ic_launcher_foreground.png`, `ic_launcher_round.png`, and `drawable*/splash.png`. +- **iOS**: `ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png` and `Splash.imageset/splash-2732x2732*.png`. + +## Web assets + +The `www/` directory is generated by `npm run build:web` and is ignored by git. Always run `npm run sync` after building the web app or changing public assets so the native projects have the latest content. + +## CI builds + +The `Mobile` workflow (`.github/workflows/mobile.yml`) builds an Android debug APK and uploads it as the `huly-android-debug-apk` artifact. It runs on pushes/PRs touching `mobile/`, and can be started manually (Actions → Mobile → Run workflow) with an optional `config` input selecting which `dev/prod/public/config-*.json` to bundle (default `config-mobile-huly.json`, pointing to the huly.app cloud backend). + +Debug builds are signed with the stable keystore committed at `android/app/debug.keystore` (standard debug credentials, not a secret), so any debug APK — from CI or a local machine — installs over a previously installed one without uninstalling. `versionCode`/`versionName` can be overridden via the `MOBILE_VERSION_CODE`/`MOBILE_VERSION_NAME` environment variables; CI sets them from the workflow run number. + +iOS is not built in CI yet: an installable artifact requires a macOS runner plus an Apple Developer certificate and provisioning profile. + +## Adding platforms (first-time setup) + +If the `ios/` or `android/` folders are missing (e.g. after a fresh clone), run once from `mobile/`: + +```bash +npm run add:ios +npm run add:android +``` + +Then run `npm run sync` before opening the native projects. diff --git a/mobile/android/.gitignore b/mobile/android/.gitignore new file mode 100644 index 0000000000..48354a3dfc --- /dev/null +++ b/mobile/android/.gitignore @@ -0,0 +1,101 @@ +# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore + +# Built application files +*.apk +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +# Uncomment the following line in case you need and you don't have the release build type files in your app +# release/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea/workspace.xml +.idea/tasks.xml +.idea/gradle.xml +.idea/assetWizardSettings.xml +.idea/dictionaries +.idea/libraries +# Android Studio 3 in .gitignore file. +.idea/caches +.idea/modules.xml +# Comment next line if keeping position of elements in Navigation Editor is relevant for you +.idea/navEditor.xml + +# Keystore files +# Uncomment the following lines if you do not want to check your keystore files in. +#*.jks +#*.keystore + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ + +# Google Services (e.g. APIs or Firebase) +# google-services.json + +# Freeline +freeline.py +freeline/ +freeline_project_description.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +# lint/reports/ + +# Android Profiling +*.hprof + +# Cordova plugins for Capacitor +capacitor-cordova-android-plugins + +# Copied web assets +app/src/main/assets/public + +# Generated Config files +app/src/main/assets/capacitor.config.json +app/src/main/assets/capacitor.plugins.json +app/src/main/res/xml/config.xml diff --git a/mobile/android/app/.gitignore b/mobile/android/app/.gitignore new file mode 100644 index 0000000000..043df802a2 --- /dev/null +++ b/mobile/android/app/.gitignore @@ -0,0 +1,2 @@ +/build/* +!/build/.npmkeep diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle new file mode 100644 index 0000000000..50d0442ae7 --- /dev/null +++ b/mobile/android/app/build.gradle @@ -0,0 +1,64 @@ +apply plugin: 'com.android.application' + +android { + namespace "org.platformcollective.app" + compileSdk rootProject.ext.compileSdkVersion + sourceSets { + main.assets.exclude '**/*.gz' + } + defaultConfig { + applicationId "org.platformcollective.app" + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + // Overridable in CI to distinguish test builds (see .github/workflows/mobile.yml) + versionCode Integer.parseInt(System.getenv('MOBILE_VERSION_CODE') ?: '1') + versionName(System.getenv('MOBILE_VERSION_NAME') ?: '1.0') + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + aaptOptions { + // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. + // Exclude *.gz to avoid "Duplicate resources" (same base file + .gz both present from web build). + // Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61 + ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~:*.gz' + } + } + signingConfigs { + // Stable debug keystore committed to the repo so debug APKs built anywhere + // (CI, any developer machine) share one signature and install over each + // other without uninstall. Debug-only key, never used for release. + debug { + storeFile file('debug.keystore') + storePassword 'android' + keyAlias 'androiddebugkey' + keyPassword 'android' + } + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } +} + +dependencies { + implementation fileTree(include: ['*.jar'], dir: 'libs') + implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" + implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion" + implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion" + implementation project(':capacitor-android') + testImplementation "junit:junit:$junitVersion" + androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" + androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" + implementation project(':capacitor-cordova-android-plugins') +} + +apply from: 'capacitor.build.gradle' + +try { + def servicesJSON = file('google-services.json') + if (servicesJSON.text) { + apply plugin: 'com.google.gms.google-services' + } +} catch(Exception e) { + logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work") +} diff --git a/mobile/android/app/capacitor.build.gradle b/mobile/android/app/capacitor.build.gradle new file mode 100644 index 0000000000..db26f4aa8e --- /dev/null +++ b/mobile/android/app/capacitor.build.gradle @@ -0,0 +1,20 @@ +// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN + +android { + compileOptions { + sourceCompatibility JavaVersion.VERSION_21 + targetCompatibility JavaVersion.VERSION_21 + } +} + +apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" +dependencies { + implementation project(':capacitor-network') + implementation project(':capacitor-splash-screen') + +} + + +if (hasProperty('postBuildExtras')) { + postBuildExtras() +} diff --git a/mobile/android/app/debug.keystore b/mobile/android/app/debug.keystore new file mode 100644 index 0000000000..2bf96b26f5 Binary files /dev/null and b/mobile/android/app/debug.keystore differ diff --git a/mobile/android/app/proguard-rules.pro b/mobile/android/app/proguard-rules.pro new file mode 100644 index 0000000000..f1b424510d --- /dev/null +++ b/mobile/android/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/mobile/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java b/mobile/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java new file mode 100644 index 0000000000..f2c2217efa --- /dev/null +++ b/mobile/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java @@ -0,0 +1,26 @@ +package com.getcapacitor.myapp; + +import static org.junit.Assert.*; + +import android.content.Context; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** + * Instrumented test, which will execute on an Android device. + * + * @see Testing documentation + */ +@RunWith(AndroidJUnit4.class) +public class ExampleInstrumentedTest { + + @Test + public void useAppContext() throws Exception { + // Context of the app under test. + Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + + assertEquals("com.getcapacitor.app", appContext.getPackageName()); + } +} diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..9f066491ef --- /dev/null +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/android/app/src/main/java/org/platformcollective/app/MainActivity.java b/mobile/android/app/src/main/java/org/platformcollective/app/MainActivity.java new file mode 100644 index 0000000000..16abac452c --- /dev/null +++ b/mobile/android/app/src/main/java/org/platformcollective/app/MainActivity.java @@ -0,0 +1,390 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package org.platformcollective.app; + +import android.Manifest; +import android.app.DownloadManager; +import android.content.Context; +import android.content.pm.PackageManager; +import android.graphics.Bitmap; +import android.net.ConnectivityManager; +import android.net.Network; +import android.net.NetworkCapabilities; +import android.net.NetworkRequest; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Environment; +import android.util.Log; +import android.view.View; +import android.webkit.CookieManager; +import android.webkit.URLUtil; +import android.webkit.WebResourceError; +import android.webkit.WebResourceRequest; +import android.webkit.WebView; +import android.widget.Button; +import android.widget.FrameLayout; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; + +import com.getcapacitor.Bridge; +import com.getcapacitor.BridgeActivity; +import com.getcapacitor.BridgeWebViewClient; + +/** + * Main activity for the Huly mobile app. + * Implements native error handling for WebView load failures and network monitoring. + */ +public class MainActivity extends BridgeActivity { + private static final String TAG = "HulyMainActivity"; + private static final int REQUEST_POST_NOTIFICATIONS = 1001; + + // Error codes we handle + private static final int ERROR_HOST_LOOKUP = -2; // ERR_NAME_NOT_RESOLVED + private static final int ERROR_CONNECT = -6; // ERR_CONNECTION_REFUSED + private static final int ERROR_TIMEOUT = -8; // ERR_TIMED_OUT + private static final int ERROR_IO = -7; // ERR_IO_PENDING + private static final int ERROR_UNKNOWN = -1; // ERROR_UNKNOWN + + private View errorView; + private boolean isShowingError = false; + private boolean hasLoadedSuccessfully = false; + private ConnectivityManager.NetworkCallback networkCallback; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // Set up the custom WebViewClient after the bridge is ready + Bridge bridge = this.getBridge(); + if (bridge != null) { + WebView webView = bridge.getWebView(); + if (webView != null) { + webView.setWebViewClient(new HulyWebViewClient(bridge)); + setupDownloadListener(webView); + } + } + + // Set up error view + setupErrorView(); + + // Set up network monitoring + setupNetworkMonitoring(); + + // Android 13+ requires a runtime grant before any notification can be shown + requestNotificationPermissionIfNeeded(); + + // Check initial connectivity + if (!isNetworkAvailable()) { + Log.w(TAG, "No network available on startup"); + // Don't show error immediately, let WebView try to load first + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + // Unregister network callback + if (networkCallback != null) { + ConnectivityManager cm = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE); + if (cm != null) { + cm.unregisterNetworkCallback(networkCallback); + } + } + } + + /** + * Ask for notification permission on Android 13+. Declared in the manifest, + * but notifications stay silently blocked until the user grants it at runtime. + */ + private void requestNotificationPermissionIfNeeded() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + requestPermissions(new String[] { Manifest.permission.POST_NOTIFICATIONS }, REQUEST_POST_NOTIFICATIONS); + } + } + + /** + * Route WebView downloads (attachments, exports) to the system DownloadManager. + * The WebView itself silently ignores downloads without a listener. + * blob:/data: URLs cannot go through DownloadManager and are not handled here. + */ + private void setupDownloadListener(WebView webView) { + webView.setDownloadListener((url, userAgent, contentDisposition, mimeType, contentLength) -> { + if (!URLUtil.isNetworkUrl(url)) { + Log.w(TAG, "Unsupported download url scheme: " + url); + return; + } + try { + DownloadManager.Request request = new DownloadManager.Request(Uri.parse(url)); + request.setMimeType(mimeType); + String cookies = CookieManager.getInstance().getCookie(url); + if (cookies != null) { + request.addRequestHeader("Cookie", cookies); + } + request.addRequestHeader("User-Agent", userAgent); + String fileName = URLUtil.guessFileName(url, contentDisposition, mimeType); + request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED); + request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName); + DownloadManager dm = (DownloadManager) getSystemService(Context.DOWNLOAD_SERVICE); + if (dm != null) { + dm.enqueue(request); + Toast.makeText(this, getString(R.string.download_started, fileName), Toast.LENGTH_SHORT).show(); + } + } catch (Exception e) { + Log.e(TAG, "Failed to start download: " + url, e); + } + }); + } + + /** + * Set up the error view that overlays the WebView when errors occur. + */ + private void setupErrorView() { + errorView = getLayoutInflater().inflate(R.layout.error_screen, null); + + Button retryButton = errorView.findViewById(R.id.retryButton); + if (retryButton != null) { + retryButton.setOnClickListener(v -> retryLoading()); + } + + // Add error view to the content but keep it hidden + FrameLayout.LayoutParams params = new FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ); + + // Find the root view and add error view + View rootView = findViewById(android.R.id.content); + if (rootView instanceof FrameLayout) { + ((FrameLayout) rootView).addView(errorView, params); + } + + errorView.setVisibility(View.GONE); + } + + /** + * Set up network connectivity monitoring. + */ + private void setupNetworkMonitoring() { + ConnectivityManager cm = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE); + if (cm == null) return; + + networkCallback = new ConnectivityManager.NetworkCallback() { + @Override + public void onAvailable(@NonNull Network network) { + Log.d(TAG, "Network available"); + // If we're showing error and network is back, offer retry + runOnUiThread(() -> { + if (isShowingError) { + updateErrorMessage(getString(R.string.error_network_restored)); + } + }); + } + + @Override + public void onLost(@NonNull Network network) { + Log.d(TAG, "Network lost"); + // Only show error if we haven't loaded successfully + runOnUiThread(() -> { + if (!hasLoadedSuccessfully) { + showErrorScreen(getString(R.string.error_no_network)); + } + }); + } + }; + + NetworkRequest request = new NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build(); + + cm.registerNetworkCallback(request, networkCallback); + } + + /** + * Check if network is currently available. + */ + private boolean isNetworkAvailable() { + ConnectivityManager cm = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE); + if (cm == null) return false; + + Network network = cm.getActiveNetwork(); + if (network == null) return false; + + NetworkCapabilities caps = cm.getNetworkCapabilities(network); + return caps != null && caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET); + } + + /** + * Show the native error screen. + */ + private void showErrorScreen(String message) { + if (errorView == null) return; + + isShowingError = true; + + TextView messageView = errorView.findViewById(R.id.errorMessage); + if (messageView != null && message != null) { + messageView.setText(message); + } + + errorView.setVisibility(View.VISIBLE); + + // Hide WebView + Bridge bridge = getBridge(); + if (bridge != null && bridge.getWebView() != null) { + bridge.getWebView().setVisibility(View.INVISIBLE); + } + } + + /** + * Hide the error screen and show WebView. + */ + private void hideErrorScreen() { + if (errorView == null) return; + + isShowingError = false; + errorView.setVisibility(View.GONE); + + // Show WebView + Bridge bridge = getBridge(); + if (bridge != null && bridge.getWebView() != null) { + bridge.getWebView().setVisibility(View.VISIBLE); + } + } + + /** + * Update the error message without changing visibility. + */ + private void updateErrorMessage(String message) { + if (errorView == null) return; + + TextView messageView = errorView.findViewById(R.id.errorMessage); + if (messageView != null && message != null) { + messageView.setText(message); + } + } + + /** + * Retry loading the WebView content. + */ + private void retryLoading() { + Log.d(TAG, "Retrying WebView load"); + + hideErrorScreen(); + hasLoadedSuccessfully = false; + + Bridge bridge = getBridge(); + if (bridge != null && bridge.getWebView() != null) { + bridge.getWebView().reload(); + } + } + + /** + * Custom WebViewClient that intercepts load errors and shows native error UI. + */ + private class HulyWebViewClient extends BridgeWebViewClient { + public HulyWebViewClient(Bridge bridge) { + super(bridge); + } + + @Override + public void onPageStarted(WebView view, String url, Bitmap favicon) { + super.onPageStarted(view, url, favicon); + Log.d(TAG, "Page started loading: " + url); + } + + @Override + public void onPageFinished(WebView view, String url) { + super.onPageFinished(view, url); + Log.d(TAG, "Page finished loading: " + url); + + // Mark as successfully loaded + hasLoadedSuccessfully = true; + + // Hide error screen if it was showing + runOnUiThread(() -> hideErrorScreen()); + } + + @Override + public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) { + super.onReceivedError(view, request, error); + + // Only handle errors for the main frame + if (request.isForMainFrame()) { + int errorCode = error.getErrorCode(); + String description = error.getDescription().toString(); + String url = request.getUrl().toString(); + + Log.e(TAG, "WebView error: code=" + errorCode + ", desc=" + description + ", url=" + url); + + // Handle connection-related errors + if (isConnectionError(errorCode)) { + runOnUiThread(() -> { + String message = getErrorMessage(errorCode); + showErrorScreen(message); + }); + } + } + } + + @Override + public void onReceivedError(WebView view, int errorCode, String description, String failingUrl) { + super.onReceivedError(view, errorCode, description, failingUrl); + + Log.e(TAG, "WebView error (legacy): code=" + errorCode + ", desc=" + description + ", url=" + failingUrl); + + // Handle connection-related errors + if (isConnectionError(errorCode)) { + runOnUiThread(() -> { + String message = getErrorMessage(errorCode); + showErrorScreen(message); + }); + } + } + + /** + * Check if the error code is a connection-related error. + */ + private boolean isConnectionError(int errorCode) { + return errorCode == ERROR_HOST_LOOKUP || + errorCode == ERROR_CONNECT || + errorCode == ERROR_TIMEOUT || + errorCode == ERROR_IO || + errorCode == ERROR_UNKNOWN; + } + + /** + * Get a user-friendly error message for the error code. + */ + private String getErrorMessage(int errorCode) { + switch (errorCode) { + case ERROR_HOST_LOOKUP: + case ERROR_CONNECT: + return getString(R.string.error_server_unavailable); + case ERROR_TIMEOUT: + return getString(R.string.error_connection_timeout); + default: + if (!isNetworkAvailable()) { + return getString(R.string.error_no_network); + } + return getString(R.string.error_server_unavailable); + } + } + } +} diff --git a/mobile/android/app/src/main/res/drawable-land-hdpi/splash.png b/mobile/android/app/src/main/res/drawable-land-hdpi/splash.png new file mode 100644 index 0000000000..fd84f9b333 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-land-hdpi/splash.png differ diff --git a/mobile/android/app/src/main/res/drawable-land-mdpi/splash.png b/mobile/android/app/src/main/res/drawable-land-mdpi/splash.png new file mode 100644 index 0000000000..fd84f9b333 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-land-mdpi/splash.png differ diff --git a/mobile/android/app/src/main/res/drawable-land-xhdpi/splash.png b/mobile/android/app/src/main/res/drawable-land-xhdpi/splash.png new file mode 100644 index 0000000000..fd84f9b333 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-land-xhdpi/splash.png differ diff --git a/mobile/android/app/src/main/res/drawable-land-xxhdpi/splash.png b/mobile/android/app/src/main/res/drawable-land-xxhdpi/splash.png new file mode 100644 index 0000000000..fd84f9b333 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-land-xxhdpi/splash.png differ diff --git a/mobile/android/app/src/main/res/drawable-land-xxxhdpi/splash.png b/mobile/android/app/src/main/res/drawable-land-xxxhdpi/splash.png new file mode 100644 index 0000000000..fd84f9b333 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-land-xxxhdpi/splash.png differ diff --git a/mobile/android/app/src/main/res/drawable-port-hdpi/splash.png b/mobile/android/app/src/main/res/drawable-port-hdpi/splash.png new file mode 100644 index 0000000000..fd84f9b333 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-port-hdpi/splash.png differ diff --git a/mobile/android/app/src/main/res/drawable-port-mdpi/splash.png b/mobile/android/app/src/main/res/drawable-port-mdpi/splash.png new file mode 100644 index 0000000000..fd84f9b333 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-port-mdpi/splash.png differ diff --git a/mobile/android/app/src/main/res/drawable-port-xhdpi/splash.png b/mobile/android/app/src/main/res/drawable-port-xhdpi/splash.png new file mode 100644 index 0000000000..fd84f9b333 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-port-xhdpi/splash.png differ diff --git a/mobile/android/app/src/main/res/drawable-port-xxhdpi/splash.png b/mobile/android/app/src/main/res/drawable-port-xxhdpi/splash.png new file mode 100644 index 0000000000..fd84f9b333 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-port-xxhdpi/splash.png differ diff --git a/mobile/android/app/src/main/res/drawable-port-xxxhdpi/splash.png b/mobile/android/app/src/main/res/drawable-port-xxxhdpi/splash.png new file mode 100644 index 0000000000..fd84f9b333 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-port-xxxhdpi/splash.png differ diff --git a/mobile/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/mobile/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000000..c7bd21dbd8 --- /dev/null +++ b/mobile/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + diff --git a/mobile/android/app/src/main/res/drawable/ic_launcher_background.xml b/mobile/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000000..d5fccc538c --- /dev/null +++ b/mobile/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/android/app/src/main/res/drawable/retry_button_background.xml b/mobile/android/app/src/main/res/drawable/retry_button_background.xml new file mode 100644 index 0000000000..67e3bbfae0 --- /dev/null +++ b/mobile/android/app/src/main/res/drawable/retry_button_background.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/mobile/android/app/src/main/res/drawable/splash.png b/mobile/android/app/src/main/res/drawable/splash.png new file mode 100644 index 0000000000..fd84f9b333 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable/splash.png differ diff --git a/mobile/android/app/src/main/res/drawable/splash_background.xml b/mobile/android/app/src/main/res/drawable/splash_background.xml new file mode 100644 index 0000000000..bff600e3b3 --- /dev/null +++ b/mobile/android/app/src/main/res/drawable/splash_background.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/mobile/android/app/src/main/res/layout/activity_main.xml b/mobile/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000000..b5ad138701 --- /dev/null +++ b/mobile/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/mobile/android/app/src/main/res/layout/error_screen.xml b/mobile/android/app/src/main/res/layout/error_screen.xml new file mode 100644 index 0000000000..62cb49524e --- /dev/null +++ b/mobile/android/app/src/main/res/layout/error_screen.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + +