diff --git a/.github/workflows/_android-build.yml b/.github/workflows/_android-build.yml new file mode 100644 index 0000000..9d81bcc --- /dev/null +++ b/.github/workflows/_android-build.yml @@ -0,0 +1,174 @@ +name: Android Build + +on: + workflow_call: + inputs: + environment_name: + required: true + type: string + build_command: + required: true + type: string + expected_firebase_project_id: + required: true + type: string + run_ktlint: + required: false + type: boolean + default: false + run_lint: + required: false + type: boolean + default: false + use_keystore: + required: false + type: boolean + default: false + upload_artifact: + required: false + type: boolean + default: false + artifact_name: + required: false + type: string + default: android-build + artifact_path: + required: false + type: string + default: ./composeApp/build/outputs/apk/release/composeApp-release.apk + lint_artifact_name: + required: false + type: string + default: android-lint-results + +env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + +jobs: + build: + environment: ${{ inputs.environment_name }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'adopt' + cache: gradle + + - name: Validate Gradle wrapper + uses: gradle/actions/wrapper-validation@v3 + + - name: Run KtLint + if: ${{ inputs.run_ktlint }} + run: ./gradlew ktlintCheck + + - name: Decrypt Android keystore + if: ${{ inputs.use_keystore }} + run: | + set -euo pipefail + missing=() + for name in \ + ANDROID_KEYSTORE_ENCRYPTED \ + ANDROID_KEYSTORE_PASSWORD \ + ANDROID_KEYSTORE_STORE_PASSWORD \ + ANDROID_KEY_ALIAS \ + ANDROID_KEY_PASSWORD + do + if [ -z "${!name:-}" ]; then + missing+=("$name") + fi + done + + if [ ${#missing[@]} -gt 0 ]; then + printf 'Missing required Android signing secrets:\n' + printf ' - %s\n' "${missing[@]}" + exit 1 + fi + + printf '%s' "$ANDROID_KEYSTORE_ENCRYPTED" > keystore.asc + gpg -d --passphrase "$ANDROID_KEYSTORE_PASSWORD" --batch keystore.asc > keystore.jks + if [ ! -s keystore.jks ]; then + echo "keystore.jks is missing or empty after decrypting ANDROID_KEYSTORE_ENCRYPTED" + exit 1 + fi + KEYSTORE_INFO=$(keytool -list -v -keystore keystore.jks -storepass "$ANDROID_KEYSTORE_STORE_PASSWORD" -alias "$ANDROID_KEY_ALIAS") + echo "$KEYSTORE_INFO" | grep -q "PrivateKeyEntry" + KEYSTORE_TYPE=$(echo "$KEYSTORE_INFO" | sed -n 's/^Keystore type: //p' | head -n 1) + if [ "$KEYSTORE_TYPE" = "JKS" ]; then + keytool -certreq -keystore keystore.jks -storepass "$ANDROID_KEYSTORE_STORE_PASSWORD" -alias "$ANDROID_KEY_ALIAS" -keypass "$ANDROID_KEY_PASSWORD" -file android-release-signing.csr >/dev/null + rm -f android-release-signing.csr + fi + env: + ANDROID_KEYSTORE_ENCRYPTED: ${{ secrets.ANDROID_KEYSTORE_ENCRYPTED }} + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEYSTORE_STORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_STORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + + - name: Decode Android Firebase config + run: | + set -euo pipefail + if [ -z "${ANDROID_GOOGLE_SERVICES_JSON_BASE64:-}" ]; then + echo "ANDROID_GOOGLE_SERVICES_JSON_BASE64 secret is missing or empty" + exit 1 + fi + echo "$ANDROID_GOOGLE_SERVICES_JSON_BASE64" | base64 --decode > composeApp/google-services.json + node - "${{ inputs.expected_firebase_project_id }}" <<'NODE' + const fs = require("fs"); + const expectedProjectId = process.argv[2]; + const expectedPackage = "dev.arkbuilders.drop"; + const cfg = JSON.parse(fs.readFileSync("composeApp/google-services.json", "utf8")); + const clients = cfg.client || []; + if (!cfg.project_info || !cfg.project_info.project_id) { + throw new Error("Missing project_info.project_id"); + } + if (cfg.project_info.project_id !== expectedProjectId) { + throw new Error(`Firebase project mismatch. Expected ${expectedProjectId}, got ${cfg.project_info.project_id}`); + } + const matchingClient = clients.find((client) => + client.client_info && + client.client_info.android_client_info && + client.client_info.android_client_info.package_name === expectedPackage + ); + if (!matchingClient) { + throw new Error(`Missing Firebase client for ${expectedPackage}`); + } + const appId = matchingClient.client_info.mobilesdk_app_id; + if (!appId || !appId.includes(":android:")) { + throw new Error("Firebase Android client is missing a valid mobilesdk_app_id"); + } + const apiKeys = matchingClient.api_key || []; + if (!apiKeys.some((apiKey) => apiKey.current_key)) { + throw new Error("Firebase Android client is missing api_key.current_key"); + } + NODE + env: + ANDROID_GOOGLE_SERVICES_JSON_BASE64: ${{ secrets.ANDROID_GOOGLE_SERVICES_JSON_BASE64 }} + + - name: Build Android + run: ${{ inputs.build_command }} + env: + ANDROID_KEYSTORE_STORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_STORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + + - name: Run Android lint + if: ${{ inputs.run_lint }} + run: ./gradlew lint + + - name: Upload Android lint results + if: ${{ always() && inputs.run_lint }} + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.lint_artifact_name }} + path: ./composeApp/build/reports/*.html + + - name: Upload Android artifact + if: ${{ inputs.upload_artifact }} + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.artifact_name }} + path: ${{ inputs.artifact_path }} diff --git a/.github/workflows/_ios-testflight.yml b/.github/workflows/_ios-testflight.yml new file mode 100644 index 0000000..cedee96 --- /dev/null +++ b/.github/workflows/_ios-testflight.yml @@ -0,0 +1,219 @@ +name: iOS TestFlight + +on: + workflow_call: + inputs: + environment_name: + required: true + type: string + artifact_name: + required: true + type: string + log_artifact_name: + required: true + type: string + expected_firebase_project_id: + required: true + type: string + +jobs: + build: + runs-on: macos-26 + environment: ${{ inputs.environment_name }} + env: + APP_BUNDLE_ID: dev.ark-builders.drop + APP_TEAM_ID: SQNXHTL7FT + IOS_P12_PASSWORD: ${{ secrets.IOS_P12_PASSWORD }} + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Validate secrets + run: | + set -euo pipefail + missing=() + for name in \ + IOS_P12_BASE64 \ + IOS_P12_PASSWORD \ + IOS_PROFILE_BASE64 \ + ASC_API_KEY_BASE64 \ + ASC_KEY_ID \ + ASC_ISSUER_ID \ + IOS_GOOGLE_SERVICE_INFO_PLIST_BASE64 + do + if [ -z "${!name:-}" ]; then + missing+=("$name") + fi + done + + if [ ${#missing[@]} -gt 0 ]; then + printf 'Missing required %s secrets:\n' "${{ inputs.environment_name }}" + printf ' - %s\n' "${missing[@]}" + exit 1 + fi + env: + IOS_P12_BASE64: ${{ secrets.IOS_P12_BASE64 }} + IOS_PROFILE_BASE64: ${{ secrets.IOS_PROFILE_BASE64 }} + ASC_API_KEY_BASE64: ${{ secrets.ASC_API_KEY_BASE64 }} + IOS_GOOGLE_SERVICE_INFO_PLIST_BASE64: ${{ secrets.IOS_GOOGLE_SERVICE_INFO_PLIST_BASE64 }} + + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.3" + bundler-cache: true + + - name: Install gems + run: bundle install + + - name: Build Kotlin framework for Release + run: | + export JAVA_HOME=$(/usr/libexec/java_home -v 17) + ./gradlew :shared:assembleSharedReleaseXCFramework + mkdir -p shared/build/XCFrameworks/debug + cp -R shared/build/XCFrameworks/release/shared.xcframework shared/build/XCFrameworks/debug/ + + - name: Ensure XCFramework Info.plist + run: | + set -euo pipefail + XCF_DIR="shared/build/XCFrameworks/debug/shared.xcframework" + if [ ! -f "$XCF_DIR/Info.plist" ]; then + cp shared/build/XCFrameworks/release/shared.xcframework/Info.plist "$XCF_DIR/" 2>/dev/null || \ + cp shared/XCFramework-Info.plist "$XCF_DIR/Info.plist" + fi + + - name: Select Xcode version + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '26.2' + + - name: Decode iOS certificate + run: | + set -euo pipefail + echo "$IOS_P12_BASE64" | base64 --decode > cert.p12 + if [ "$(stat -f%z cert.p12)" -lt 100 ]; then + echo "cert.p12 is too small, likely corrupted" + exit 1 + fi + env: + IOS_P12_BASE64: ${{ secrets.IOS_P12_BASE64 }} + + - name: Validate iOS certificate + run: | + set -euo pipefail + openssl pkcs12 -in cert.p12 -legacy -nodes -passin env:IOS_P12_PASSWORD 2>&1 | grep -q "PRIVATE KEY" + + - name: Decode provisioning profile + run: | + set -euo pipefail + echo "$IOS_PROFILE_BASE64" | base64 --decode > profile.mobileprovision + if [ "$(stat -f%z profile.mobileprovision)" -lt 100 ]; then + echo "profile.mobileprovision is too small, likely corrupted" + exit 1 + fi + security cms -D -i profile.mobileprovision > profile.plist + plutil -lint profile.plist + PROFILE_TEAM_ID=$(/usr/libexec/PlistBuddy -c "Print :TeamIdentifier:0" profile.plist) + if [ "$PROFILE_TEAM_ID" != "$APP_TEAM_ID" ]; then + echo "Provisioning profile TeamIdentifier mismatch. Expected $APP_TEAM_ID, got $PROFILE_TEAM_ID" + exit 1 + fi + PROFILE_APP_ID=$(/usr/libexec/PlistBuddy -c "Print :Entitlements:application-identifier" profile.plist) + EXPECTED_APP_ID="$APP_TEAM_ID.$APP_BUNDLE_ID" + if [ "$PROFILE_APP_ID" != "$EXPECTED_APP_ID" ]; then + echo "Provisioning profile application-identifier mismatch. Expected $EXPECTED_APP_ID, got $PROFILE_APP_ID" + exit 1 + fi + env: + IOS_PROFILE_BASE64: ${{ secrets.IOS_PROFILE_BASE64 }} + + - name: Decode and validate Firebase config + run: | + set -euo pipefail + PLIST_PATH="iosApp/iosApp/GoogleService-Info.plist" + echo "$IOS_GOOGLE_SERVICE_INFO_PLIST_BASE64" | base64 -D > "$PLIST_PATH" + plutil -lint "$PLIST_PATH" + for key in GOOGLE_APP_ID BUNDLE_ID API_KEY GCM_SENDER_ID PROJECT_ID; do + /usr/libexec/PlistBuddy -c "Print :$key" "$PLIST_PATH" >/dev/null + done + PLIST_PROJECT_ID=$(/usr/libexec/PlistBuddy -c "Print :PROJECT_ID" "$PLIST_PATH") + if [ "$PLIST_PROJECT_ID" != "${{ inputs.expected_firebase_project_id }}" ]; then + echo "Firebase PROJECT_ID mismatch. Expected ${{ inputs.expected_firebase_project_id }}, got $PLIST_PROJECT_ID" + exit 1 + fi + PLIST_BUNDLE_ID=$(/usr/libexec/PlistBuddy -c "Print :BUNDLE_ID" "$PLIST_PATH") + if [ "$PLIST_BUNDLE_ID" != "$APP_BUNDLE_ID" ]; then + echo "Firebase BUNDLE_ID mismatch. Expected $APP_BUNDLE_ID, got $PLIST_BUNDLE_ID" + exit 1 + fi + GOOGLE_APP_ID=$(/usr/libexec/PlistBuddy -c "Print :GOOGLE_APP_ID" "$PLIST_PATH") + if [[ "$GOOGLE_APP_ID" != *":ios:"* ]]; then + echo "Firebase GOOGLE_APP_ID does not look like an iOS app id: $GOOGLE_APP_ID" + exit 1 + fi + API_KEY=$(/usr/libexec/PlistBuddy -c "Print :API_KEY" "$PLIST_PATH") + if [ -z "$API_KEY" ]; then + echo "Firebase API_KEY is empty" + exit 1 + fi + env: + IOS_GOOGLE_SERVICE_INFO_PLIST_BASE64: ${{ secrets.IOS_GOOGLE_SERVICE_INFO_PLIST_BASE64 }} + + - name: Setup App Store Connect API Key + run: | + set -euo pipefail + mkdir -p ~/.fastlane + echo "$ASC_API_KEY_BASE64" | base64 --decode > ~/.fastlane/AuthKey.p8 + chmod 600 ~/.fastlane/AuthKey.p8 + if [ ! -s ~/.fastlane/AuthKey.p8 ]; then + echo "AuthKey.p8 is missing or empty" + exit 1 + fi + if ! grep -q "BEGIN PRIVATE KEY" ~/.fastlane/AuthKey.p8; then + echo "AuthKey.p8 does not look like an App Store Connect private key" + exit 1 + fi + env: + ASC_API_KEY_BASE64: ${{ secrets.ASC_API_KEY_BASE64 }} + + - name: Set build number for TestFlight + run: | + BUILD_NUM=$(date +%s) + sed -i.bak "s/CURRENT_PROJECT_VERSION=.*/CURRENT_PROJECT_VERSION=$BUILD_NUM/" iosApp/Configuration/Config.xcconfig + rm -f iosApp/Configuration/Config.xcconfig.bak + + - name: Build and upload to TestFlight + run: bundle exec fastlane beta + env: + EXPECTED_FIREBASE_PROJECT_ID: ${{ inputs.expected_firebase_project_id }} + IOS_P12_PASSWORD: ${{ secrets.IOS_P12_PASSWORD }} + APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.ASC_KEY_ID }} + APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + + - name: Upload IPA artifact + uses: actions/upload-artifact@v4 + if: success() + with: + name: ${{ inputs.artifact_name }} + path: build/ARK-Drop.ipa + retention-days: 30 + + - name: Upload build logs + uses: actions/upload-artifact@v4 + if: always() + with: + name: ${{ inputs.log_artifact_name }} + path: | + ~/Library/Logs/gym/ + build/ + retention-days: 7 + + - name: Cleanup + if: always() + run: | + rm -f cert.p12 profile.mobileprovision profile.plist build/Bundled-GoogleService-Info.plist ~/.fastlane/AuthKey.p8 + security delete-keychain "$HOME/Library/Keychains/ark_drop_ci_keychain-db" >/dev/null 2>&1 || true diff --git a/.github/workflows/android-dev.yml b/.github/workflows/android-dev.yml new file mode 100644 index 0000000..60c41af --- /dev/null +++ b/.github/workflows/android-dev.yml @@ -0,0 +1,21 @@ +name: Android Dev + +on: + pull_request: + branches: [ main ] + +jobs: + android: + uses: ./.github/workflows/_android-build.yml + with: + environment_name: Development + build_command: ./gradlew assembleRelease + expected_firebase_project_id: ark-drop-test + run_ktlint: true + run_lint: true + use_keystore: true + upload_artifact: true + artifact_name: ark-drop-android-dev + artifact_path: ./composeApp/build/outputs/apk/release/composeApp-release.apk + lint_artifact_name: android-dev-lint-results + secrets: inherit diff --git a/.github/workflows/android-prod.yml b/.github/workflows/android-prod.yml new file mode 100644 index 0000000..9255c44 --- /dev/null +++ b/.github/workflows/android-prod.yml @@ -0,0 +1,22 @@ +name: Android Prod + +on: + push: + tags: + - 'v*' + +jobs: + android: + uses: ./.github/workflows/_android-build.yml + with: + environment_name: Production + build_command: ./gradlew assembleRelease + expected_firebase_project_id: ark-drop-prod + run_ktlint: true + run_lint: true + use_keystore: true + upload_artifact: true + artifact_name: ark-drop-android-prod + artifact_path: ./composeApp/build/outputs/apk/release/composeApp-release.apk + lint_artifact_name: android-prod-lint-results + secrets: inherit diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index ac96b4c..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,100 +0,0 @@ -name: Build the app - -on: - push: - branches: [ main ] - pull_request: - branches: [ main ] - -env: - BRANCH_NAME: ${{ github.ref_name }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - -jobs: - check: - needs: [lint, ktlint] - if: ${{ startsWith(github.actor, 'dependabot') }} - environment: Development - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up JDK 17 - uses: actions/setup-java@v4 - with: - java-version: '17' - distribution: 'adopt' - cache: gradle - - - name: Validate Gradle wrapper - uses: gradle/actions/wrapper-validation@v3 - - - name: Build debug APK - run: ./gradlew assembleDebug - - build: - needs: [lint, ktlint] - environment: Development - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up JDK 17 - uses: actions/setup-java@v4 - with: - java-version: '17' - distribution: 'adopt' - cache: gradle - - - name: Validate Gradle wrapper - uses: gradle/actions/wrapper-validation@v3 - - - name: Decrypt the keystore for signing - run: | - echo "${{ secrets.KEYSTORE_ENCRYPTED }}" > keystore.asc - gpg -d --passphrase "${{ secrets.KEYSTORE_PASSWORD }}" --batch keystore.asc > keystore.jks - - - name: Build release APK - run: ./gradlew assembleRelease - - - name: Upload APK - uses: actions/upload-artifact@v4 - with: - name: ark-drop-release - path: ./composeApp/build/outputs/apk/release/composeApp-release.apk - - lint: - environment: Development - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up JDK 17 - uses: actions/setup-java@v4 - with: - java-version: '17' - distribution: 'adopt' - - - name: Run linter - run: ./gradlew lint - - - uses: actions/upload-artifact@v4 - with: - name: lint-results - path: ./composeApp/build/reports/*.html - - ktlint: - environment: Development - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up JDK 17 - uses: actions/setup-java@v4 - with: - java-version: '17' - distribution: 'adopt' - - - name: Run KtLint - run: ./gradlew ktlintCheck diff --git a/.github/workflows/ios-dev.yml b/.github/workflows/ios-dev.yml new file mode 100644 index 0000000..4a5593c --- /dev/null +++ b/.github/workflows/ios-dev.yml @@ -0,0 +1,15 @@ +name: iOS Dev + +on: + pull_request: + branches: [ main ] + +jobs: + ios: + uses: ./.github/workflows/_ios-testflight.yml + with: + environment_name: Development + expected_firebase_project_id: ark-drop-test + artifact_name: ARK-Drop-iOS-Dev-${{ github.run_number }}.ipa + log_artifact_name: ios-dev-build-logs + secrets: inherit diff --git a/.github/workflows/ios-prod.yml b/.github/workflows/ios-prod.yml new file mode 100644 index 0000000..8fc39eb --- /dev/null +++ b/.github/workflows/ios-prod.yml @@ -0,0 +1,16 @@ +name: iOS Prod + +on: + push: + tags: + - 'v*' + +jobs: + ios: + uses: ./.github/workflows/_ios-testflight.yml + with: + environment_name: Production + expected_firebase_project_id: ark-drop-prod + artifact_name: ARK-Drop-iOS-Prod-${{ github.ref_name }}.ipa + log_artifact_name: ios-prod-build-logs + secrets: inherit diff --git a/.gitignore b/.gitignore index adfa9bf..ab94ee3 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,28 @@ captures !*.xcworkspace/contents.xcworkspacedata **/xcshareddata/WorkspaceSettings.xcsettings node_modules/ + +# Firebase (contains API keys — never commit) +GoogleService-Info.plist +google-services.json + +# Fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output +fastlane/.env* +# Certificates and profiles (NEVER commit these) +*.p12 +*.mobileprovision +*.cer +*.certSigningRequest +*.p8 + +# Bundler +vendor/bundle/ +.bundle/ +Gemfile.lock + +# Generated docs (do not push) +IOS_IMPLEMENTATION_PR.md diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..3f26f57 --- /dev/null +++ b/Gemfile @@ -0,0 +1,5 @@ +source "https://rubygems.org" + +gem "fastlane", "~> 2.219" +gem "cocoapods", "~> 1.15" +gem "multi_json", "~> 1.15" diff --git a/PRIVACY.md b/PRIVACY.md index e02a3f7..030212d 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -6,15 +6,18 @@ Drop ("we", "our", or "us") is committed to protecting your privacy. This Privacy Policy explains how we handle information when you use our mobile application. -## Information We Don't Collect +If you want maximum privacy, you can build Drop yourself from source. Self-built versions can omit Firebase Analytics entirely. + +## Information We Collect Drop is designed with privacy in mind: -- **No Personal Data Collection**: We do not collect, store, or transmit any personal information -- **No Analytics**: We do not use analytics services or tracking tools +- **Privacy-Preserving Analytics**: We collect aggregate app usage events such as transfer started, transfer completed, transfer failed, file count, coarse file-size bucket, and duration - **No Advertising**: We do not display ads or work with advertising networks - **No Cloud Storage**: Files are never uploaded to our servers or any cloud service +Analytics events never include file contents, file names, file paths, transfer tickets, confirmation codes, peer names, profile names, or avatars. + ## How Drop Works - **Direct Transfer**: Files are transferred directly between devices over the internet @@ -47,7 +50,7 @@ Drop requests the following permissions: ## Third-Party Services -Drop does not integrate with any third-party services that collect data. +Drop uses Firebase Analytics for aggregate product metrics and Firebase Crashlytics for crash diagnostics. ## Changes to This Policy @@ -61,4 +64,8 @@ If you have any questions about this Privacy Policy, please contact us at: ## Your Rights -Since we don't collect any personal data, there is no personal data to access, modify, or delete. All your data remains on your device under your control. +We do not collect file contents or transfer secrets. Files, profiles, and transfer history remain on your device under your control. + +## Analytics Details + +Firebase Analytics events may include exact file counts, total transfer size in bytes, coarse file-size bucket, transfer duration, transfer phase, and error category. They do not include file contents, file names, file paths, transfer tickets, confirmation codes, peer names, profile names, or avatars. diff --git a/README.md b/README.md index b2dab0f..8a37e16 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Drop - Secure File Sharing -Drop is a secure, peer-to-peer file sharing application for Android that allows you to transfer files between devices over the internet. +Drop is a secure, peer-to-peer file sharing application for Android and iOS that allows you to transfer files between devices over the internet. ## Features @@ -42,66 +42,63 @@ To create a release build: ./gradlew assembleRelease ``` -## Google Play Store Release +## CI, Firebase, And Release Builds -### Setup +GitHub Actions uses two environments for both platforms: -1. **Create a release keystore** (if you don't have one): -```bash -keytool -genkeypair -alias drop-key -keyalg RSA -keysize 2048 -validity 10000 -keystore release-keystore.jks -``` +- `Development`: PR builds against the test Firebase project, `ark-drop-test`. +- `Production`: `v*` tag builds against the production Firebase project, `ark-drop-prod`. -2. **Set up GitHub Secrets**: - - `KEYSTORE_BASE64`: Base64 encoded keystore file - - `KEY_ALIAS`, `KEY_PASSWORD`, `KEYSTORE_PASSWORD`: Keystore credentials - - `PLAY_STORE_CREDENTIALS`: Google Play Console service account JSON +### Development Workflows -3. **Google Play Console Setup**: - - Create a new app in Google Play Console - - Set up app signing - - Create a service account for API access - - Download the service account JSON file +- `android-dev.yml`: runs on pull requests to `main`, builds a signed Android release APK with the development Firebase config, runs KtLint and Android lint. +- `ios-dev.yml`: runs on pull requests to `main`, builds iOS and uploads to TestFlight with the development Firebase config. -### Release Process +### Production Workflows -#### Automated Release (Recommended) +Create a production tag to run both production workflows: -1. **Create a release tag**: ```bash git tag v1.0.0 git push origin v1.0.0 ``` -2. **Manual workflow dispatch**: - - Go to GitHub Actions - - Select "Release to Google Play" - - Choose the release track (internal/alpha/beta/production) - - Run workflow +- `android-prod.yml`: builds a signed Android release APK using the `Production` environment. +- `ios-prod.yml`: builds iOS with the `Production` environment and uploads to TestFlight. -#### Manual Release +### GitHub Secrets Checklist -1. **Build release bundle**: -```bash -./gradlew bundleRelease -``` +Repository secrets shared by iOS TestFlight workflows: -2. **Upload to Play Console**: -```bash -./gradlew publishBundle -``` +- [ ] `ASC_API_KEY_BASE64`: base64-encoded App Store Connect API private key `.p8`. +- [ ] `ASC_ISSUER_ID`: App Store Connect API issuer ID. +- [ ] `ASC_KEY_ID`: App Store Connect API key ID. +- [ ] `IOS_P12_BASE64`: base64-encoded Apple Distribution certificate `.p12`. +- [ ] `IOS_P12_PASSWORD`: password for `IOS_P12_BASE64`. +- [ ] `IOS_PROFILE_BASE64`: base64-encoded App Store provisioning profile `.mobileprovision`. + +For the current single Apple Developer account / single App Store app setup, these iOS signing and App Store Connect values are shared by both iOS workflows. + +Firebase config secrets present in both `Development` and `Production`: + +- [ ] `ANDROID_GOOGLE_SERVICES_JSON_BASE64`: base64-encoded Android `google-services.json`. +- [ ] `IOS_GOOGLE_SERVICE_INFO_PLIST_BASE64`: base64-encoded iOS `GoogleService-Info.plist`. + +Use the same secret names in both environments. Only the secret values differ: `Development` values must come from `ark-drop-test`, and `Production` values must come from `ark-drop-prod`. + +Firebase config is CI-only and must not be committed. Local development and tests must work without Firebase config; iOS Firebase analytics/crash-reporting calls no-op when no bundled Firebase plist is present. -### Release Tracks +Android signing secrets present in both `Development` and `Production`: -- **Internal**: For internal testing (up to 100 testers) -- **Alpha**: For alpha testing (open or closed) -- **Beta**: For beta testing (open or closed) -- **Production**: For public release +- [ ] `ANDROID_KEYSTORE_ENCRYPTED`: encrypted ASCII-armored Android keystore, written to `keystore.asc` in CI. +- [ ] `ANDROID_KEYSTORE_PASSWORD`: passphrase used by GPG to decrypt `ANDROID_KEYSTORE_ENCRYPTED`. +- [ ] `ANDROID_KEYSTORE_STORE_PASSWORD`: password for the decrypted JKS file. +- [ ] `ANDROID_KEY_ALIAS`: alias of the signing key inside the JKS. +- [ ] `ANDROID_KEY_PASSWORD`: password for the signing key inside the JKS. -### Version Management +Both Android workflows validate that all signing secrets are present, that the encrypted keystore can be decrypted, that the alias exists, and that the key password can access the private key. Android development artifacts are not distributed through Google Play, but they use the same release build path with development environment secrets. -Versions are automatically managed: -- **Version Code**: GitHub run number -- **Version Name**: Git tag (for releases) or dev build number +The workflow validates Firebase project IDs before building, so a development workflow must receive `ark-drop-test` configs and a production workflow must receive `ark-drop-prod` configs. ## How Drop Works @@ -172,6 +169,6 @@ Drop respects your privacy: - No data is collected or stored on external servers - All transfers are direct device-to-device over internet - Files are encrypted during transfer -- No analytics or tracking +- Privacy-preserving analytics only: aggregate transfer events, counts, coarse size buckets, durations, and error categories; never file contents, file names, paths, transfer tickets, confirmation codes, peer names, profile names, or avatars For more details, see our [Privacy Policy](PRIVACY.md). diff --git a/build.gradle.kts b/build.gradle.kts index ceb7696..ad88b89 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,4 +7,7 @@ plugins { alias(libs.plugins.composeCompiler) apply false alias(libs.plugins.kotlinMultiplatform) apply false alias(libs.plugins.ktlint.gradle) apply false + alias(libs.plugins.skie) apply false + alias(libs.plugins.google.services) apply false + alias(libs.plugins.firebase.crashlytics) apply false } diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index e6603fe..0be619a 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -8,8 +8,15 @@ plugins { alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeCompiler) alias(libs.plugins.ktlint.gradle) + alias(libs.plugins.google.services) + alias(libs.plugins.firebase.crashlytics) } +fun signingValue(name: String): String? = + providers.environmentVariable(name) + .orElse(providers.gradleProperty(name)) + .orNull + kotlin { androidTarget { compilerOptions { @@ -44,6 +51,10 @@ kotlin { implementation(libs.material.icons.extended) implementation(libs.kotlinx.datetime) + + implementation(project.dependencies.platform(libs.firebase.bom)) + implementation(libs.firebase.crashlytics) + implementation(libs.firebase.analytics) } commonMain.dependencies { implementation(compose.runtime) @@ -67,11 +78,11 @@ android { compileSdk = libs.versions.android.compileSdk.get().toInt() signingConfigs { - create("testRelease") { + create("release") { storeFile = project.rootProject.file("keystore.jks") - storePassword = "sw0rdf1sh" - keyAlias = "ark-builders-test" - keyPassword = "rybamech" + storePassword = signingValue("ANDROID_KEYSTORE_STORE_PASSWORD") + keyAlias = signingValue("ANDROID_KEY_ALIAS") + keyPassword = signingValue("ANDROID_KEY_PASSWORD") } } @@ -89,7 +100,7 @@ android { } buildTypes { getByName("release") { - signingConfig = signingConfigs.getByName("testRelease") + signingConfig = signingConfigs.getByName("release") isMinifyEnabled = false } } diff --git a/composeApp/src/androidMain/kotlin/dev/arkbuilders/drop/presentation/MainActivity.kt b/composeApp/src/androidMain/kotlin/dev/arkbuilders/drop/presentation/MainActivity.kt index 32811c6..a668f05 100644 --- a/composeApp/src/androidMain/kotlin/dev/arkbuilders/drop/presentation/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/dev/arkbuilders/drop/presentation/MainActivity.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost @@ -17,6 +18,8 @@ import androidx.navigation.compose.rememberNavController import androidx.navigation.navDeepLink import dev.arkbuilders.drop.domain.repository.ProfileRepo import dev.arkbuilders.drop.domain.repository.TransferSessionRepo +import dev.arkbuilders.drop.instrumentation.AnalyticsEvents +import dev.arkbuilders.drop.instrumentation.AnalyticsReporter import dev.arkbuilders.drop.presentation.history.History import dev.arkbuilders.drop.presentation.home.Home import dev.arkbuilders.drop.presentation.navigation.DropDestination @@ -32,9 +35,15 @@ class MainActivity : ComponentActivity() { private val transferSessionRepo: TransferSessionRepo = get() + private val analyticsReporter: AnalyticsReporter = get() + override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) + analyticsReporter.logEvent( + AnalyticsEvents.APP_START, + mapOf(AnalyticsEvents.PARAM_PLATFORM to "android"), + ) setContent { DropTheme { @@ -50,6 +59,7 @@ class MainActivity : ComponentActivity() { .padding(innerPadding), profileRepo = profileRepo, transferSessionRepo = transferSessionRepo, + analyticsReporter = analyticsReporter, ) } } @@ -63,7 +73,23 @@ fun DropNavigation( navController: NavHostController = rememberNavController(), profileRepo: ProfileRepo, transferSessionRepo: TransferSessionRepo, + analyticsReporter: AnalyticsReporter, ) { + LaunchedEffect(navController) { + navController.currentBackStackEntryFlow.collect { backStackEntry -> + val screenName = backStackEntry.destination.route?.toAnalyticsScreenName() + if (screenName != null) { + analyticsReporter.logEvent( + AnalyticsEvents.SCREEN_VIEW, + mapOf( + AnalyticsEvents.PARAM_SCREEN_NAME to screenName, + AnalyticsEvents.PARAM_SCREEN_CLASS to screenName, + ), + ) + } + } + } + NavHost( navController = navController, startDestination = DropDestination.Home.route, @@ -90,6 +116,13 @@ fun DropNavigation( }, ), ) { + val ticket = it.arguments?.getString("ticket").orEmpty() + val confirmation = it.arguments?.getString("confirmation").orEmpty() + LaunchedEffect(ticket, confirmation) { + if (ticket.isNotEmpty() || confirmation.isNotEmpty()) { + analyticsReporter.logEvent(AnalyticsEvents.RECEIVE_DEEP_LINK_OPENED) + } + } Receive( navController = navController, ) @@ -112,3 +145,14 @@ fun DropNavigation( } } } + +private fun String.toAnalyticsScreenName(): String? = + when (this) { + DropDestination.Home.route -> "home" + DropDestination.Send.route -> "send" + DropDestination.Receive.route -> "receive" + DropDestination.History.route -> "history" + DropDestination.EditProfile.route -> "edit_profile" + DropDestination.About.route -> "about" + else -> null + } diff --git a/composeApp/src/androidMain/kotlin/dev/arkbuilders/drop/presentation/receive/components/ReceiveScanningCard.kt b/composeApp/src/androidMain/kotlin/dev/arkbuilders/drop/presentation/receive/components/ReceiveScanningCard.kt index 6df4ede..a0e42ce 100644 --- a/composeApp/src/androidMain/kotlin/dev/arkbuilders/drop/presentation/receive/components/ReceiveScanningCard.kt +++ b/composeApp/src/androidMain/kotlin/dev/arkbuilders/drop/presentation/receive/components/ReceiveScanningCard.kt @@ -213,7 +213,8 @@ private fun processImageProxy( when (barcode.valueType) { Barcode.TYPE_TEXT, Barcode.TYPE_URL -> { barcode.rawValue?.let { value -> - // Parse Drop QR code format: drop://receive?ticket=...&confirmation=... + // Parse Drop QR code format: + // drop://receive?ticket=...&confirmation=... if (value.startsWith("drop://receive?")) { try { val uri = value.toUri() diff --git a/composeApp/src/androidMain/kotlin/dev/arkbuilders/drop/presentation/send/components/phase/WaitingForReceiverPhase.kt b/composeApp/src/androidMain/kotlin/dev/arkbuilders/drop/presentation/send/components/phase/WaitingForReceiverPhase.kt index ad92f8c..0c4578a 100644 --- a/composeApp/src/androidMain/kotlin/dev/arkbuilders/drop/presentation/send/components/phase/WaitingForReceiverPhase.kt +++ b/composeApp/src/androidMain/kotlin/dev/arkbuilders/drop/presentation/send/components/phase/WaitingForReceiverPhase.kt @@ -33,10 +33,13 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import compose.icons.TablerIcons import compose.icons.tablericons.Copy +import dev.arkbuilders.drop.instrumentation.AnalyticsEvents +import dev.arkbuilders.drop.instrumentation.AnalyticsReporter import dev.arkbuilders.drop.presentation.send.components.ButtonSize import dev.arkbuilders.drop.presentation.send.components.ButtonVariant import dev.arkbuilders.drop.presentation.send.components.SendButton import dev.arkbuilders.drop.presentation.send.components.SendLoadingIndicator +import org.koin.compose.koinInject @Composable fun WaitingForReceiverPhase( @@ -46,6 +49,7 @@ fun WaitingForReceiverPhase( onCancel: () -> Unit, ) { val context = LocalContext.current + val analyticsReporter: AnalyticsReporter = koinInject() val bitmap = remember(qrBitmap) { BitmapFactory.decodeByteArray(qrBitmap, 0, qrBitmap.size) @@ -99,6 +103,7 @@ fun WaitingForReceiverPhase( SendButton( onClick = { copyToClipboard(context, copyString) + analyticsReporter.logEvent(AnalyticsEvents.SEND_CODE_COPIED) Toast.makeText(context, "Code copied!", Toast.LENGTH_SHORT).show() }, variant = ButtonVariant.Secondary, diff --git a/fastlane/Appfile b/fastlane/Appfile new file mode 100644 index 0000000..abf00a4 --- /dev/null +++ b/fastlane/Appfile @@ -0,0 +1,8 @@ +# Appfile for ARK Drop (KMP) iOS App + +app_identifier(ENV['APP_BUNDLE_ID'] || "dev.ark-builders.drop") +apple_id(ENV['APPLE_ID'] || "") # Optional: Apple ID for the developer account +team_id(ENV['APP_TEAM_ID'] || "SQNXHTL7FT") + +# For more information about the Appfile, see: +# https://docs.fastlane.tools/advanced/#appfile diff --git a/fastlane/Fastfile b/fastlane/Fastfile new file mode 100644 index 0000000..0c03b90 --- /dev/null +++ b/fastlane/Fastfile @@ -0,0 +1,249 @@ +# Fastfile for ARK Drop (KMP) iOS App + +default_platform(:ios) + +platform :ios do + # Environment variables + before_all do + ENV['MATCH_READONLY'] = 'true' if ENV['CI'] + end + + desc "Push a new beta build to TestFlight" + lane :beta do + keychain_name = "ark_drop_ci_keychain" + keychain_password = "ark-drop-ci-keychain" + keychain_path = File.expand_path("~/Library/Keychains/#{keychain_name}-db") + + UI.important "📥 Importing certificate manually..." + + # Files are in parent directory (workflow creates them in repo root, fastlane runs from fastlane/ subdir) + cert_path = File.expand_path("../cert.p12") + profile_path = File.expand_path("../profile.mobileprovision") + + # Verify cert.p12 exists + unless File.exist?(cert_path) + UI.user_error!("❌ cert.p12 file not found at: #{cert_path}") + end + UI.message "✅ cert.p12 exists (#{File.size(cert_path)} bytes)" + + if ENV['CI'] + UI.important "🔐 Creating temporary CI keychain..." + sh("security delete-keychain '#{keychain_path}' >/dev/null 2>&1 || true") + sh("security create-keychain -p '#{keychain_password}' '#{keychain_path}'") + sh("security set-keychain-settings -lut 3600 '#{keychain_path}'") + sh("security unlock-keychain -p '#{keychain_password}' '#{keychain_path}'") + sh("security list-keychains -d user -s '#{keychain_path}'") + sh("security default-keychain -s '#{keychain_path}'") + end + + # Import certificate using security command directly + # Use -A to allow all applications to access this item + sh("security import '#{cert_path}' -k '#{keychain_path}' -P '#{ENV['IOS_P12_PASSWORD']}' -A") + + # Set key partition list for the keychain (allow codesign to access without prompting) + sh("security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k '#{keychain_password}' '#{keychain_path}'") + + # Verify certificate was imported + UI.important "🔍 Verifying certificate import..." + sh("security find-identity -v -p codesigning '#{keychain_path}'") + + # Additional debugging: Check ALL certificates and keys in keychain + UI.important "🔍 Checking ALL items in keychain..." + sh("security dump-keychain #{keychain_path} | head -50") rescue UI.message("Could not dump keychain") + + # Check certificate details from the p12 + UI.important "🔍 Certificate details from p12 file:" + sh("openssl pkcs12 -in '#{cert_path}' -legacy -passin pass:'#{ENV['IOS_P12_PASSWORD']}' -nokeys 2>&1 | openssl x509 -noout -subject -dates -fingerprint") rescue UI.message("Could not read p12") + + # Check for private key in keychain + UI.important "🔍 Checking for private keys in keychain:" + sh("security dump-keychain #{keychain_path} | grep -A5 'class.*0x00000011' || echo 'No private keys found'") rescue nil + + # Ensure keychain is in search path and unlocked + UI.important "🔓 Configuring keychain..." + sh("security list-keychains -d user -s '#{keychain_path}'") + sh("security default-keychain -s '#{keychain_path}'") + sh("security unlock-keychain -p '#{keychain_password}' '#{keychain_path}'") + sh("security set-keychain-settings -lut 3600 '#{keychain_path}'") + + # Install provisioning profile manually + UI.important "📱 Installing provisioning profile..." + + # Verify profile file exists + unless File.exist?(profile_path) + UI.user_error!("❌ profile.mobileprovision file not found at: #{profile_path}") + end + UI.message "✅ profile.mobileprovision exists (#{File.size(profile_path)} bytes)" + + # Create directory if it doesn't exist + profiles_dir = File.expand_path("~/Library/MobileDevice/Provisioning Profiles") + sh("mkdir -p '#{profiles_dir}'") + + # Get profile UUID + profile_uuid = sh("security cms -D -i '#{profile_path}' | plutil -extract UUID raw -").strip + UI.message "📋 Profile UUID: #{profile_uuid}" + + # Copy profile to the correct location with UUID as filename + sh("cp '#{profile_path}' '#{profiles_dir}/#{profile_uuid}.mobileprovision'") + + # Store profile UUID for build step + ENV['PROVISIONING_PROFILE_UUID'] = profile_uuid + + # Verify profile was installed + UI.important "🔍 Verifying profile installation..." + sh("ls -la '#{profiles_dir}'") + + # Increment build number (using timestamp to avoid conflicts) + increment_build_number( + build_number: Time.now.to_i.to_s, + xcodeproj: "iosApp/iosApp.xcodeproj" + ) + + # Get the App Store Connect API Key + api_key = app_store_connect_api_key( + key_id: ENV['ASC_KEY_ID'], + issuer_id: ENV['ASC_ISSUER_ID'], + key_filepath: "~/.fastlane/AuthKey.p8", + duration: 1200, + in_house: false + ) + + # Build the app (project in iosApp/ subdirectory) + # Use project settings for code signing (Manual for app, Automatic for SPM) + UI.important "🔨 Starting build with verbose logging..." + UI.important "Configuration: Release" + UI.important "Scheme: iosApp" + UI.important "Bundle ID: #{ENV['APP_BUNDLE_ID']}" + UI.important "Team ID: #{ENV['APP_TEAM_ID']}" + + build_app( + scheme: "iosApp", + project: "iosApp/iosApp.xcodeproj", + configuration: "Release", + export_method: "app-store", + output_directory: "./build", + output_name: "ARK-Drop.ipa", + verbose: true, + buildlog_path: "./build/logs", + # Skip macro validation in CI (Swift macros not enabled) and specify profile + xcargs: "-skipMacroValidation PROVISIONING_PROFILE_SPECIFIER=#{ENV['PROVISIONING_PROFILE_UUID']}", + # Export options to specify provisioning profile for IPA export + export_options: { + method: "app-store", + teamID: ENV['APP_TEAM_ID'], + provisioningProfiles: { + ENV['APP_BUNDLE_ID'] => ENV['PROVISIONING_PROFILE_UUID'] + }, + signingCertificate: "Apple Distribution", + signingStyle: "manual" + } + ) + + expected_firebase_project_id = ENV['EXPECTED_FIREBASE_PROJECT_ID'] + if expected_firebase_project_id.to_s.empty? + UI.user_error!("EXPECTED_FIREBASE_PROJECT_ID is required") + end + + repo_root = File.expand_path("..", __dir__) + ipa_path = lane_context[SharedValues::IPA_OUTPUT_PATH] || File.join(repo_root, "build", "ARK-Drop.ipa") + bundled_firebase_plist = File.join(repo_root, "build", "Bundled-GoogleService-Info.plist") + sh("mkdir -p '#{File.dirname(bundled_firebase_plist)}'") + sh("unzip -p '#{ipa_path}' 'Payload/*.app/GoogleService-Info.plist' > '#{bundled_firebase_plist}'") + sh("plutil -lint '#{bundled_firebase_plist}'") + + bundled_project_id = sh("/usr/libexec/PlistBuddy -c 'Print :PROJECT_ID' '#{bundled_firebase_plist}'").strip + if bundled_project_id != expected_firebase_project_id + UI.user_error!("Bundled Firebase PROJECT_ID mismatch. Expected #{expected_firebase_project_id}, got #{bundled_project_id}") + end + + bundled_bundle_id = sh("/usr/libexec/PlistBuddy -c 'Print :BUNDLE_ID' '#{bundled_firebase_plist}'").strip + if bundled_bundle_id != ENV['APP_BUNDLE_ID'] + UI.user_error!("Bundled Firebase BUNDLE_ID mismatch. Expected #{ENV['APP_BUNDLE_ID']}, got #{bundled_bundle_id}") + end + + dsym_paths = [ + lane_context[SharedValues::DSYM_OUTPUT_PATH], + *Dir[File.join(repo_root, "build", "*.dSYM.zip")], + *Dir[File.join(repo_root, "build", "*.dSYM")], + *Dir[File.join(repo_root, "build", "*.xcarchive", "dSYMs", "*.dSYM")], + *Dir[File.expand_path("~/Library/Developer/Xcode/DerivedData/**/SourcePackages/artifacts/**/*.dSYM.zip")], + *Dir[File.expand_path("~/Library/Developer/Xcode/DerivedData/**/SourcePackages/artifacts/**/*.dSYM")], + *Dir[File.expand_path("~/Library/Developer/Xcode/DerivedData/**/SourcePackages/checkouts/**/*.dSYM.zip")], + *Dir[File.expand_path("~/Library/Developer/Xcode/DerivedData/**/SourcePackages/checkouts/**/*.dSYM")], + *Dir[File.expand_path("~/Library/Developer/Xcode/DerivedData/**/Build/Intermediates.noindex/ArchiveIntermediates/**/*.dSYM")], + *Dir[File.expand_path("~/Library/Developer/Xcode/DerivedData/**/Build/Products/**/*.dSYM")] + ].compact.uniq.select { |path| File.exist?(path) } + if dsym_paths.empty? + UI.user_error!("dSYM output not found for Crashlytics upload") + end + + upload_symbols = ( + Dir[File.expand_path("~/Library/Developer/Xcode/DerivedData/**/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/upload-symbols")] + + Dir[File.join(repo_root, "**/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/upload-symbols")] + + Dir[File.join(repo_root, "**/FirebaseCrashlytics/upload-symbols")] + ).first + if upload_symbols.to_s.empty? || !File.exist?(upload_symbols) + UI.user_error!("Firebase Crashlytics upload-symbols script not found") + end + + firebase_plist = File.join(repo_root, "iosApp/iosApp/GoogleService-Info.plist") + UI.important "📤 Uploading dSYM to Firebase Crashlytics..." + sh("chmod +x '#{upload_symbols}'") + dsym_paths.each do |dsym_path| + sh("'#{upload_symbols}' -gsp '#{firebase_plist}' -p ios '#{dsym_path}'") + end + + UI.success "✅ Build completed successfully!" + + # Upload to TestFlight + upload_to_testflight( + api_key: api_key, + skip_waiting_for_build_processing: true, + skip_submission: true, + distribute_external: false, + notify_external_testers: false, + ipa: "./build/ARK-Drop.ipa" + ) + end + + desc "Build the app for release" + lane :build do + # Increment build number + increment_build_number( + build_number: Time.now.to_i.to_s, + xcodeproj: "iosApp/iosApp.xcodeproj" + ) + + # Build the app + build_app( + scheme: "iosApp", + project: "iosApp/iosApp.xcodeproj", + configuration: "Release", + export_method: "app-store", + clean: true, + output_directory: "./build", + output_name: "ARK-Drop.ipa" + ) + end + + desc "Run tests" + lane :test do + run_tests( + scheme: "iosApp", + project: "iosApp/iosApp.xcodeproj", + devices: ["iPhone 15 Pro"] + ) + end + + # Error handling + error do |lane, exception| + UI.error("Error in lane #{lane}: #{exception.message}") + + # Show the last 100 lines of the build log if it exists + build_log = Dir.glob("./build/logs/**/*.log").first + if build_log && File.exist?(build_log) + UI.important "📋 Last 100 lines of build log:" + sh("tail -100 '#{build_log}'") rescue nil + end + end +end diff --git a/gradle.properties b/gradle.properties index 6f8e6ea..a402540 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,7 +6,12 @@ kotlin.daemon.jvmargs=-Xmx3072M org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8 org.gradle.configuration-cache=true org.gradle.caching=true +# org.gradle.java.home - removed: use JAVA_HOME env var (macOS: /usr/local/opt/openjdk@17) #Android android.nonTransitiveRClass=true -android.useAndroidX=true \ No newline at end of file +android.useAndroidX=true + +#Kotlin Multiplatform - Enable cinterop commonization +kotlin.mpp.enableCInteropCommonization=true +kotlin.mpp.enableCInteropCommonization.nowarn=true \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0043277..a653d85 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -34,6 +34,10 @@ navigationCompose = "2.8.4" arkAbout = "0.2.1" materialIconsExtended = "1.7.8" ktlintGradlePlugin = "12.2.0" +skie = "0.10.9" +firebaseCrashlyticsGradle = "3.0.7" +googleServicesGradle = "4.4.4" +firebaseBom = "34.13.0" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } @@ -97,6 +101,10 @@ ark-about = { module = "dev.arkbuilders.components:about", version.ref = "arkAbo material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "materialIconsExtended"} +firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" } +firebase-crashlytics = { module = "com.google.firebase:firebase-crashlytics" } +firebase-analytics = { module = "com.google.firebase:firebase-analytics" } + [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } androidLibrary = { id = "com.android.library", version.ref = "agp" } @@ -107,3 +115,6 @@ ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } androidx-room = { id = "androidx.room", version.ref = "room" } kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } ktlint-gradle = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlintGradlePlugin" } +skie = { id = "co.touchlab.skie", version.ref = "skie" } +google-services = { id = "com.google.gms.google-services", version.ref = "googleServicesGradle" } +firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "firebaseCrashlyticsGradle" } diff --git a/iosApp/Configuration/Config.xcconfig b/iosApp/Configuration/Config.xcconfig index 705e34e..8cf0b22 100644 --- a/iosApp/Configuration/Config.xcconfig +++ b/iosApp/Configuration/Config.xcconfig @@ -1,7 +1,7 @@ TEAM_ID= PRODUCT_NAME=ARK-Drop -PRODUCT_BUNDLE_IDENTIFIER=dev.arkbuilders.drop.ARK-Drop$(TEAM_ID) +PRODUCT_BUNDLE_IDENTIFIER=dev.ark-builders.drop CURRENT_PROJECT_VERSION=1 -MARKETING_VERSION=1.0 \ No newline at end of file +MARKETING_VERSION=1.1 \ No newline at end of file diff --git a/iosApp/README.md b/iosApp/README.md new file mode 100644 index 0000000..dfbfa31 --- /dev/null +++ b/iosApp/README.md @@ -0,0 +1,209 @@ +# ARK Drop - iOS App + +Production-ready SwiftUI implementation using Kotlin Multiplatform ViewModels via SKIE. + +## Architecture + +### MVVM with KMP ViewModels + +``` +┌─────────────────────────────────────┐ +│ SwiftUI Views │ +│ (HomeView, SendView, ReceiveView) │ +└──────────────┬──────────────────────┘ + │ + │ @Published state + ▼ +┌─────────────────────────────────────┐ +│ ViewModel Wrappers │ +│ (Observe KMP ViewModels) │ +└──────────────┬──────────────────────┘ + │ + │ SKIE Bridge + ▼ +┌─────────────────────────────────────┐ +│ KMP ViewModels (Orbit MVI) │ +│ Flow → AsyncSequence │ +└─────────────────────────────────────┘ +``` + +### Key Components + +#### 1. **Core Architecture** +- `ViewModelObserver.swift` - Generic KMP ViewModel observer +- `DIContainer.swift` - Dependency injection container +- `NavigationCoordinator.swift` - Centralized navigation + +#### 2. **Theme System** +- `Colors.swift` - App color palette +- `Typography.swift` - Text styles +- `Spacing.swift` - Layout constants + +#### 3. **Reusable Components** +- `DropButton.swift` - Styled button component +- `DropCard.swift` - Card container +- `AvatarView.swift` - Avatar display +- `ProgressBar.swift` - Progress indicator +- `EmptyStateView.swift` - Empty state placeholder +- `ErrorView.swift` - Error display +- `LoadingView.swift` - Loading indicator + +#### 4. **Features** + +##### Home +- `HomeView.swift` - Main screen with Send/Receive actions +- Shows recent transfer history +- Profile management + +##### Send Files +- `SendView.swift` - Send flow coordinator +- `FileSelectionView` - File picker integration +- `WaitingForReceiverView` - QR code display +- `TransferringView` - Transfer progress +- `TransferCompleteView` - Success state + +##### Receive Files +- `ReceiveView.swift` - Receive flow coordinator +- `QRScannerView.swift` - Camera-based QR scanner +- `ManualInputView` - Manual code entry +- `ReceivingView` - Receive progress +- `ReceiveSuccessView` - Success state + +##### Profile +- `EditProfileView.swift` - Profile editing +- `AvatarPickerView` - Avatar selection +- `AboutView.swift` - App information + +##### History +- `HistoryView.swift` - Transfer history list +- `HistoryDetailCard` - History item display + +## SKIE Integration + +SKIE automatically converts: +- `Flow` → `AsyncSequence` for state/effect streams +- Kotlin coroutines → Swift async/await +- Sealed classes → Swift enums (with associated values) +- Data classes → Swift structs + +### Example Usage + +```swift +// Observe ViewModel state +for try await newState in viewModel.container.stateFlow { + self.state = newState as! HomeScreenState +} + +// Observe side effects +for try await effect in viewModel.container.sideEffectFlow { + handleEffect(effect as! HomeScreenEffect) +} +``` + +## State Management + +Each screen follows this pattern: + +1. **ViewModel Wrapper** - Observes KMP ViewModel +2. **State** - Published state from KMP +3. **Effects** - Side effects (navigation, etc.) +4. **View** - SwiftUI view that reacts to state + +```swift +@MainActor +class HomeViewModelWrapper: ObservableObject { + @Published private(set) var state: HomeScreenState + private let viewModel: HomeViewModel + + init() { + self.viewModel = DIContainer.shared.makeHomeViewModel() + // ... observe state/effects + } +} +``` + +## File Structure + +``` +iosApp/ +├── Core/ +│ ├── ViewModelObserver.swift +│ ├── DIContainer.swift +│ └── NavigationCoordinator.swift +├── Theme/ +│ ├── Colors.swift +│ ├── Typography.swift +│ └── Spacing.swift +├── Components/ +│ ├── DropButton.swift +│ ├── DropCard.swift +│ ├── AvatarView.swift +│ ├── ProgressBar.swift +│ ├── EmptyStateView.swift +│ ├── ErrorView.swift +│ └── LoadingView.swift +├── Features/ +│ ├── Home/ +│ │ └── HomeView.swift +│ ├── Send/ +│ │ └── SendView.swift +│ ├── Receive/ +│ │ ├── ReceiveView.swift +│ │ └── QRScannerView.swift +│ ├── History/ +│ │ └── HistoryView.swift +│ └── Profile/ +│ ├── EditProfileView.swift +│ └── AboutView.swift +├── Utilities/ +│ └── Extensions.swift +└── iOSApp.swift (main entry) +``` + +## Best Practices Implemented + +### iOS Development +- ✅ MVVM architecture +- ✅ SwiftUI declarative UI +- ✅ Combine for reactive patterns +- ✅ @MainActor for thread safety +- ✅ Property wrappers (@Published, @StateObject, @EnvironmentObject) +- ✅ Proper memory management (weak self, deinit cleanup) + +### Design +- ✅ iOS Human Interface Guidelines +- ✅ Adaptive layouts +- ✅ Native iOS components +- ✅ Proper spacing and typography +- ✅ Accessibility support ready + +### Code Quality +- ✅ Clear separation of concerns +- ✅ Reusable components +- ✅ Type-safe navigation +- ✅ Error handling +- ✅ Preview support for SwiftUI + +## Requirements + +- iOS 16.0+ +- Xcode 15.0+ +- Swift 5.9+ + +## Permissions + +The app requires: +- **Camera** - For QR code scanning (receive files) +- **Photo Library** - For file selection (send files) + +These are declared in `Info.plist`. + +## Running the App + +1. Open `iosApp.xcodeproj` in Xcode +2. Select a simulator or device +3. Build and run (⌘R) + +The Gradle build will automatically compile the Kotlin shared module. + +Firebase config is provided by CI for shared development and production builds. Local iOS runs do not require `GoogleService-Info.plist`; Firebase instrumentation is disabled when the plist is absent. diff --git a/iosApp/add_files_to_xcode.sh b/iosApp/add_files_to_xcode.sh new file mode 100644 index 0000000..6a56672 --- /dev/null +++ b/iosApp/add_files_to_xcode.sh @@ -0,0 +1,51 @@ +#!/bin/bash + +# Script to verify all Swift files are in place +# Run this to see what files need to be added to Xcode + +echo "🔍 Checking Swift files structure..." +echo "" + +check_dir() { + local dir=$1 + if [ -d "$dir" ]; then + echo "✅ $dir exists" + ls -1 "$dir"/*.swift 2>/dev/null | wc -l | xargs echo " Files:" + else + echo "❌ $dir missing" + fi +} + +cd "$(dirname "$0")/iosApp" + +echo "Core Architecture:" +check_dir "Core" +echo "" + +echo "Theme System:" +check_dir "Theme" +echo "" + +echo "Components:" +check_dir "Components" +echo "" + +echo "Features:" +check_dir "Features/Home" +check_dir "Features/Send" +check_dir "Features/Receive" +check_dir "Features/History" +check_dir "Features/Profile" +echo "" + +echo "Utilities:" +check_dir "Utilities" +echo "" + +echo "==========================================" +echo "Next steps:" +echo "1. Open Xcode: open iosApp.xcodeproj" +echo "2. Right-click 'iosApp' folder → 'Add Files to iosApp...'" +echo "3. Select each folder above and add them" +echo "4. Make sure 'Add to targets: iosApp' is checked" +echo "==========================================" diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 55e6d7f..04213e4 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -6,8 +6,17 @@ objectVersion = 77; objects = { +/* Begin PBXBuildFile section */ + 2B1B97902FC2089900B54BD3 /* FirebaseAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = 2B1B978F2FC2089900B54BD3 /* FirebaseAnalytics */; }; + 2B1B97922FC2094E00B54BD3 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = 2B1B97912FC2094E00B54BD3 /* FirebaseCrashlytics */; }; + 2B1B97942FC2095A00B54BD3 /* FirebaseCore in Frameworks */ = {isa = PBXBuildFile; productRef = 2B1B97932FC2095A00B54BD3 /* FirebaseCore */; }; + 87961AEE2F239F6600972E0D /* ArkDrop in Frameworks */ = {isa = PBXBuildFile; productRef = 87961AED2F239F6600972E0D /* ArkDrop */; }; + 87A139FB2F2824560011ECBC /* shared.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 87A139FA2F2824560011ECBC /* shared.xcframework */; }; +/* End PBXBuildFile section */ + /* Begin PBXFileReference section */ - 19D5789066F6284BA1F79245 /* ARK-Drop.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ARK-Drop.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 19D5789066F6284BA1F79245 /* ARK-Drop.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "ARK-Drop.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 87A139FA2F2824560011ECBC /* shared.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = shared.xcframework; path = ../shared/build/XCFrameworks/debug/shared.xcframework; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -41,27 +50,41 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 87A139FB2F2824560011ECBC /* shared.xcframework in Frameworks */, + 2B1B97942FC2095A00B54BD3 /* FirebaseCore in Frameworks */, + 2B1B97922FC2094E00B54BD3 /* FirebaseCrashlytics in Frameworks */, + 87961AEE2F239F6600972E0D /* ArkDrop in Frameworks */, + 2B1B97902FC2089900B54BD3 /* FirebaseAnalytics in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 7BA83B6A3A88D023B32D48A4 /* Products */ = { + isa = PBXGroup; + children = ( + 19D5789066F6284BA1F79245 /* ARK-Drop.app */, + ); + name = Products; + sourceTree = ""; + }; 7C29D5070F6F808BD9FE5AB5 = { isa = PBXGroup; children = ( B23C40A330132F979EB28EA6 /* Configuration */, 64A01FA7AED3DE00C53DD51F /* iosApp */, + 87A139F92F2824560011ECBC /* Frameworks */, 7BA83B6A3A88D023B32D48A4 /* Products */, ); sourceTree = ""; }; - 7BA83B6A3A88D023B32D48A4 /* Products */ = { + 87A139F92F2824560011ECBC /* Frameworks */ = { isa = PBXGroup; children = ( - 19D5789066F6284BA1F79245 /* ARK-Drop.app */, + 87A139FA2F2824560011ECBC /* shared.xcframework */, ); - name = Products; + name = Frameworks; sourceTree = ""; }; /* End PBXGroup section */ @@ -85,6 +108,10 @@ ); name = iosApp; packageProductDependencies = ( + 87961AED2F239F6600972E0D /* ArkDrop */, + 2B1B978F2FC2089900B54BD3 /* FirebaseAnalytics */, + 2B1B97912FC2094E00B54BD3 /* FirebaseCrashlytics */, + 2B1B97932FC2095A00B54BD3 /* FirebaseCore */, ); productName = iosApp; productReference = 19D5789066F6284BA1F79245 /* ARK-Drop.app */; @@ -114,6 +141,10 @@ ); mainGroup = 7C29D5070F6F808BD9FE5AB5; minimizedProjectReferenceProxies = 1; + packageReferences = ( + 87961AEC2F239F6600972E0D /* XCRemoteSwiftPackageReference "ARK-iOS" */, + 2B1B978E2FC2089900B54BD3 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, + ); preferredProjectObjectVersion = 77; productRefGroup = 7BA83B6A3A88D023B32D48A4 /* Products */; projectDirPath = ""; @@ -152,7 +183,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\ncd \"$SRCROOT/..\"\n./gradlew :shared:embedAndSignAppleFrameworkForXcode\n"; + shellScript = "export JAVA_HOME=$(/usr/libexec/java_home -v 17)\nif [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\ncd \"$SRCROOT/..\"\n./gradlew :shared:embedAndSignAppleFrameworkForXcode\n"; }; /* End PBXShellScriptBuildPhase section */ @@ -167,6 +198,40 @@ /* End PBXSourcesBuildPhase section */ /* Begin XCBuildConfiguration section */ + 155FE75799855BBDD3763ADE /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ARCHS = "$(ARCHS_STANDARD)"; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_IDENTITY = "Apple Distribution"; + CODE_SIGN_STYLE = Manual; + DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; + DEVELOPMENT_TEAM = SQNXHTL7FT; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = iosApp/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + OTHER_LDFLAGS = ( + "$(inherited)", + "-framework", + SystemConfiguration, + ); + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "iosApp/ARK_Drop-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; 53C8DBC66AA9DEAC8B92E104 /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReferenceAnchor = B23C40A330132F979EB28EA6 /* Configuration */; @@ -207,6 +272,7 @@ ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; + FRAMEWORK_SEARCH_PATHS = "\" $(SRCROOT)/../shared/build/XCFrameworks/debug\""; GCC_C_LANGUAGE_STANDARD = gnu17; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -232,6 +298,40 @@ }; name = Debug; }; + DBD510A9A2971CBC6B4028F6 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ARCHS = "$(ARCHS_STANDARD)"; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; + DEVELOPMENT_TEAM = CJGZR6N7SF; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = iosApp/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + OTHER_LDFLAGS = ( + "$(inherited)", + "-framework", + SystemConfiguration, + ); + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "iosApp/ARK_Drop-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; F4210D925D5323C65C75B66B /* Release */ = { isa = XCBuildConfiguration; baseConfigurationReferenceAnchor = B23C40A330132F979EB28EA6 /* Configuration */; @@ -272,6 +372,7 @@ ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; + FRAMEWORK_SEARCH_PATHS = "\" $(SRCROOT)/../shared/build/XCFrameworks/debug\""; GCC_C_LANGUAGE_STANDARD = gnu17; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -290,62 +391,6 @@ }; name = Release; }; - DBD510A9A2971CBC6B4028F6 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ARCHS = arm64; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; - DEVELOPMENT_TEAM = "${TEAM_ID}"; - ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = iosApp/Info.plist; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - 155FE75799855BBDD3763ADE /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ARCHS = arm64; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; - DEVELOPMENT_TEAM = "${TEAM_ID}"; - ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = iosApp/Info.plist; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Release; - }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -368,6 +413,48 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 2B1B978E2FC2089900B54BD3 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/firebase/firebase-ios-sdk"; + requirement = { + kind = exactVersion; + version = 12.11.0; + }; + }; + 87961AEC2F239F6600972E0D /* XCRemoteSwiftPackageReference "ARK-iOS" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/ARK-Builders/ARK-iOS"; + requirement = { + kind = exactVersion; + version = 0.1.1; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 2B1B978F2FC2089900B54BD3 /* FirebaseAnalytics */ = { + isa = XCSwiftPackageProductDependency; + package = 2B1B978E2FC2089900B54BD3 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseAnalytics; + }; + 2B1B97912FC2094E00B54BD3 /* FirebaseCrashlytics */ = { + isa = XCSwiftPackageProductDependency; + package = 2B1B978E2FC2089900B54BD3 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseCrashlytics; + }; + 2B1B97932FC2095A00B54BD3 /* FirebaseCore */ = { + isa = XCSwiftPackageProductDependency; + package = 2B1B978E2FC2089900B54BD3 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseCore; + }; + 87961AED2F239F6600972E0D /* ArkDrop */ = { + isa = XCSwiftPackageProductDependency; + package = 87961AEC2F239F6600972E0D /* XCRemoteSwiftPackageReference "ARK-iOS" */; + productName = ArkDrop; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = F9D2D3C115BAF668BF03F9D4 /* Project object */; -} \ No newline at end of file +} diff --git a/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..7dd10ae --- /dev/null +++ b/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,132 @@ +{ + "originHash" : "045297a0d421f940d1c6b2ca46718afede76f1b2e25c64b8b9a59b1a8cb0afa1", + "pins" : [ + { + "identity" : "abseil-cpp-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/abseil-cpp-binary.git", + "state" : { + "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", + "version" : "1.2024072200.0" + } + }, + { + "identity" : "app-check", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/app-check.git", + "state" : { + "revision" : "61b85103a1aeed8218f17c794687781505fbbef5", + "version" : "11.2.0" + } + }, + { + "identity" : "ark-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ARK-Builders/ARK-iOS", + "state" : { + "revision" : "31388d3ce720b68a3e54637f200d83381addb327", + "version" : "0.1.1" + } + }, + { + "identity" : "firebase-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/firebase-ios-sdk", + "state" : { + "revision" : "52548902007e7beda99fcdca31f62eccc4befe38", + "version" : "12.11.0" + } + }, + { + "identity" : "google-ads-on-device-conversion-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", + "state" : { + "revision" : "23fa6970874ec8f3ac0039b3778ca8d6545d50ee", + "version" : "3.4.2" + } + }, + { + "identity" : "googleappmeasurement", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleAppMeasurement.git", + "state" : { + "revision" : "1657f705bc70255ff9d66dfcc697a85db164998f", + "version" : "12.11.0" + } + }, + { + "identity" : "googledatatransport", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleDataTransport.git", + "state" : { + "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", + "version" : "10.1.0" + } + }, + { + "identity" : "googleutilities", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleUtilities.git", + "state" : { + "revision" : "60da361632d0de02786f709bdc0c4df340f7613e", + "version" : "8.1.0" + } + }, + { + "identity" : "grpc-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/grpc-binary.git", + "state" : { + "revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6", + "version" : "1.69.1" + } + }, + { + "identity" : "gtm-session-fetcher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/gtm-session-fetcher.git", + "state" : { + "revision" : "c0ac7575d70050c2973ba2318bd5af47f8e8153a", + "version" : "5.3.0" + } + }, + { + "identity" : "interop-ios-for-google-sdks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/interop-ios-for-google-sdks.git", + "state" : { + "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", + "version" : "101.0.0" + } + }, + { + "identity" : "leveldb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/leveldb.git", + "state" : { + "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", + "version" : "1.22.5" + } + }, + { + "identity" : "nanopb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/nanopb.git", + "state" : { + "revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1", + "version" : "2.30910.0" + } + }, + { + "identity" : "promises", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/promises.git", + "state" : { + "revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", + "version" : "2.4.0" + } + } + ], + "version" : 3 +} diff --git a/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme b/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme new file mode 100644 index 0000000..62ab8ce --- /dev/null +++ b/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iosApp/iosApp/ARK_Drop-Bridging-Header.h b/iosApp/iosApp/ARK_Drop-Bridging-Header.h new file mode 100644 index 0000000..8928189 --- /dev/null +++ b/iosApp/iosApp/ARK_Drop-Bridging-Header.h @@ -0,0 +1,11 @@ +// +// ARK_Drop-Bridging-Header.h +// iosApp +// +// Bridging header to expose Objective-C types to Swift +// This file must be configured in Xcode Build Settings: +// Build Settings > Swift Compiler - General > Objective-C Bridging Header +// Set to: iosApp/ARK_Drop-Bridging-Header.h +// + +#import "ArkDropBridge.h" diff --git a/iosApp/iosApp/AnalyticsBridge.h b/iosApp/iosApp/AnalyticsBridge.h new file mode 100644 index 0000000..f5f82da --- /dev/null +++ b/iosApp/iosApp/AnalyticsBridge.h @@ -0,0 +1,6 @@ +#ifndef AnalyticsBridge_h +#define AnalyticsBridge_h + +void analytics_logEvent(const char *name, const char *jsonParams); + +#endif diff --git a/iosApp/iosApp/AnalyticsBridge.m b/iosApp/iosApp/AnalyticsBridge.m new file mode 100644 index 0000000..d31a2c7 --- /dev/null +++ b/iosApp/iosApp/AnalyticsBridge.m @@ -0,0 +1,24 @@ +#import "AnalyticsBridge.h" +@import FirebaseCore; +@import FirebaseAnalytics; + +void analytics_logEvent(const char *name, const char *jsonParams) { + if ([FIRApp defaultApp] == nil) return; + if (name == NULL) return; + + NSString *eventName = [NSString stringWithUTF8String:name]; + NSDictionary *params = nil; + + if (jsonParams != NULL) { + NSString *json = [NSString stringWithUTF8String:jsonParams]; + NSData *data = [json dataUsingEncoding:NSUTF8StringEncoding]; + NSError *error = nil; + id object = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error]; + + if (error == nil && [object isKindOfClass:[NSDictionary class]]) { + params = (NSDictionary *)object; + } + } + + [FIRAnalytics logEventWithName:eventName parameters:params]; +} diff --git a/iosApp/iosApp/ArkDropBridge.h b/iosApp/iosApp/ArkDropBridge.h new file mode 100644 index 0000000..cf15e95 --- /dev/null +++ b/iosApp/iosApp/ArkDropBridge.h @@ -0,0 +1,122 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +// Forward declarations +@protocol ArkDropSendFilesBubble; +@protocol ArkDropReceiveFilesBubble; +@protocol ArkDropSendFilesSubscriber; +@protocol ArkDropReceiveFilesSubscriber; +@protocol ArkDropSenderFileData; + +// Request types +@interface ArkDropSenderProfile : NSObject +@property (nonatomic, strong) NSString *name; +@property (nonatomic, strong, nullable) NSString *avatarB64; +- (instancetype)initWithName:(NSString *)name avatarB64:(nullable NSString *)avatarB64; +@end + +@interface ArkDropSenderFile : NSObject +@property (nonatomic, strong) NSString *name; +@property (nonatomic, strong) id data; +- (instancetype)initWithName:(NSString *)name data:(id)data; +@end + +@interface ArkDropSenderConfig : NSObject +@property (nonatomic, assign) uint64_t chunkSize; +@property (nonatomic, assign) uint64_t parallelStreams; +- (instancetype)initWithChunkSize:(uint64_t)chunkSize parallelStreams:(uint64_t)parallelStreams; +@end + +@interface ArkDropSendFilesRequest : NSObject +@property (nonatomic, strong) ArkDropSenderProfile *profile; +@property (nonatomic, strong) NSArray *files; +@property (nonatomic, strong, nullable) ArkDropSenderConfig *config; +- (instancetype)initWithProfile:(ArkDropSenderProfile *)profile + files:(NSArray *)files + config:(nullable ArkDropSenderConfig *)config; +@end + +@interface ArkDropReceiverProfile : NSObject +@property (nonatomic, strong) NSString *name; +@property (nonatomic, strong, nullable) NSString *avatarB64; +- (instancetype)initWithName:(NSString *)name avatarB64:(nullable NSString *)avatarB64; +@end + +@interface ArkDropReceiverConfig : NSObject +@property (nonatomic, assign) uint64_t chunkSize; +@property (nonatomic, assign) uint64_t parallelStreams; +- (instancetype)initWithChunkSize:(uint64_t)chunkSize parallelStreams:(uint64_t)parallelStreams; +@end + +@interface ArkDropReceiveFilesRequest : NSObject +@property (nonatomic, strong) NSString *ticket; +@property (nonatomic, assign) uint8_t confirmation; +@property (nonatomic, strong) ArkDropReceiverProfile *profile; +@property (nonatomic, strong) ArkDropReceiverConfig *config; +- (instancetype)initWithTicket:(NSString *)ticket + confirmation:(uint8_t)confirmation + profile:(ArkDropReceiverProfile *)profile + config:(ArkDropReceiverConfig *)config; +@end + +// SenderFileData protocol +@protocol ArkDropSenderFileData +- (uint64_t)len; +- (nullable NSNumber *)read; +- (NSData *)readChunkWithSize:(int32_t)size; +@end + +// SendFilesBubble protocol +@protocol ArkDropSendFilesBubble +- (NSString *)getTicket; +- (uint8_t)getConfirmation; +- (void)cancelWithCompletion:(void (^)(NSError * _Nullable))completion; +- (BOOL)isFinished; +- (BOOL)isConnected; +- (NSString *)getCreatedAt; +- (void)subscribeWithSubscriber:(id)subscriber; +- (void)unsubscribeWithSubscriber:(id)subscriber; +@end + +// ReceiveFilesBubble protocol +@protocol ArkDropReceiveFilesBubble +- (void)startWithError:(NSError * _Nullable * _Nullable)error; +- (void)cancel; +- (BOOL)isFinished; +- (BOOL)isCancelled; +- (void)subscribeWithSubscriber:(id)subscriber; +- (void)unsubscribeWithSubscriber:(id)subscriber; +@end + +// Subscriber protocols +@protocol ArkDropSendFilesSubscriber +- (NSString *)getId; +- (void)logWithMessage:(NSString *)message; +- (void)notifySendingWithName:(NSString *)name sent:(uint64_t)sent remaining:(uint64_t)remaining; +- (void)notifyConnectingWithReceiverName:(NSString *)receiverName receiverAvatarB64:(nullable NSString *)receiverAvatarB64; +@end + +@protocol ArkDropReceiveFilesSubscriber +- (NSString *)getId; +- (void)logWithMessage:(NSString *)message; +- (void)notifyReceivingWithFileId:(NSString *)fileId data:(NSData *)data; +- (void)notifyConnectingWithSenderName:(NSString *)senderName + senderAvatarB64:(nullable NSString *)senderAvatarB64 + files:(NSArray *> *)files; +@end + +// Bridge class +@class ArkDropBridge; + +@interface ArkDropBridge : NSObject ++ (void)sendFilesWithRequest:(ArkDropSendFilesRequest *)request + bubble:(id _Nullable * _Nonnull)bubble + error:(NSError * _Nullable * _Nullable)error; + ++ (void)receiveFilesWithRequest:(ArkDropReceiveFilesRequest *)request + bubble:(id _Nullable * _Nonnull)bubble + error:(NSError * _Nullable * _Nullable)error; +@end + +NS_ASSUME_NONNULL_END diff --git a/iosApp/iosApp/ArkDropBridge.m b/iosApp/iosApp/ArkDropBridge.m new file mode 100644 index 0000000..c9cfbb2 --- /dev/null +++ b/iosApp/iosApp/ArkDropBridge.m @@ -0,0 +1,125 @@ +#import "ArkDropBridge.h" + +// Forward declaration of Swift class - actual implementation linked at runtime +// This allows cinterop to generate bindings without needing the Swift header +@interface ArkDropBridgeSwift : NSObject ++ (void)sendFilesWithRequest:(ArkDropSendFilesRequest *)request + bubble:(id _Nullable __autoreleasing * _Nonnull)bubble + error:(NSError * _Nullable __autoreleasing * _Nullable)error; + ++ (void)receiveFilesWithRequest:(ArkDropReceiveFilesRequest *)request + bubble:(id _Nullable __autoreleasing * _Nonnull)bubble + error:(NSError * _Nullable __autoreleasing * _Nullable)error; +@end + +NS_ASSUME_NONNULL_BEGIN + +// Request implementations +@implementation ArkDropSenderProfile +- (instancetype)initWithName:(NSString *)name avatarB64:(nullable NSString *)avatarB64 { + self = [super init]; + if (self) { + _name = name; + _avatarB64 = avatarB64; + } + return self; +} +@end + +@implementation ArkDropSenderFile +- (instancetype)initWithName:(NSString *)name data:(id)data { + self = [super init]; + if (self) { + _name = name; + _data = data; + } + return self; +} +@end + +@implementation ArkDropSenderConfig +- (instancetype)initWithChunkSize:(uint64_t)chunkSize parallelStreams:(uint64_t)parallelStreams { + self = [super init]; + if (self) { + _chunkSize = chunkSize; + _parallelStreams = parallelStreams; + } + return self; +} +@end + +@implementation ArkDropSendFilesRequest +- (instancetype)initWithProfile:(ArkDropSenderProfile *)profile + files:(NSArray *)files + config:(nullable ArkDropSenderConfig *)config { + self = [super init]; + if (self) { + _profile = profile; + _files = files; + _config = config; + } + return self; +} +@end + +@implementation ArkDropReceiverProfile +- (instancetype)initWithName:(NSString *)name avatarB64:(nullable NSString *)avatarB64 { + self = [super init]; + if (self) { + _name = name; + _avatarB64 = avatarB64; + } + return self; +} +@end + +@implementation ArkDropReceiverConfig +- (instancetype)initWithChunkSize:(uint64_t)chunkSize parallelStreams:(uint64_t)parallelStreams { + self = [super init]; + if (self) { + _chunkSize = chunkSize; + _parallelStreams = parallelStreams; + } + return self; +} +@end + +@implementation ArkDropReceiveFilesRequest +- (instancetype)initWithTicket:(NSString *)ticket + confirmation:(uint8_t)confirmation + profile:(ArkDropReceiverProfile *)profile + config:(ArkDropReceiverConfig *)config { + self = [super init]; + if (self) { + _ticket = ticket; + _confirmation = confirmation; + _profile = profile; + _config = config; + } + return self; +} +@end + +// Bridge function implementations will call Swift code +// These are implemented in ArkDropBridge.swift using @objc exports +// The Swift bridging header will be auto-generated by Xcode + +@implementation ArkDropBridge + ++ (void)sendFilesWithRequest:(ArkDropSendFilesRequest *)request + bubble:(id _Nullable * _Nonnull)bubble + error:(NSError * _Nullable * _Nullable)error { + // Call Swift implementation + [ArkDropBridgeSwift sendFilesWithRequest:request bubble:bubble error:error]; +} + ++ (void)receiveFilesWithRequest:(ArkDropReceiveFilesRequest *)request + bubble:(id _Nullable * _Nonnull)bubble + error:(NSError * _Nullable * _Nullable)error { + // Call Swift implementation + [ArkDropBridgeSwift receiveFilesWithRequest:request bubble:bubble error:error]; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/iosApp/iosApp/ArkDropBridge.swift b/iosApp/iosApp/ArkDropBridge.swift new file mode 100644 index 0000000..a770eff --- /dev/null +++ b/iosApp/iosApp/ArkDropBridge.swift @@ -0,0 +1,317 @@ +import Foundation +import ArkDrop + +// Note: Objective-C types from ArkDropBridge.h are made available to Swift +// through the bridging header (ARK_Drop-Bridging-Header.h) +// This file should import "ArkDropBridge.h" to expose the Objective-C types + +// MARK: - Objective-C Bridge Implementation + +// Bridge class for Objective-C interop +@objc(ArkDropBridgeSwift) public class ArkDropBridgeSwift: NSObject { + @objc public static func sendFiles(withRequest request: ArkDropSendFilesRequest, + bubble: AutoreleasingUnsafeMutablePointer, + error: NSErrorPointer) { + let semaphore = DispatchSemaphore(value: 0) + var resultBubble: ArkDropSendFilesBubble? + var resultError: NSError? + + Task { + do { + let swiftRequest = convertToSwiftSendRequest(request) + let swiftBubble = try await ArkDrop.sendFiles(request: swiftRequest) + resultBubble = ArkDropSendFilesBubbleImpl(bubble: swiftBubble) + } catch let err as NSError { + resultError = err + } catch { + resultError = NSError(domain: "ArkDropBridge", code: -1, userInfo: [NSLocalizedDescriptionKey: String(describing: error)]) + } + semaphore.signal() + } + + // Wait for async operation to complete + semaphore.wait() + + if let err = resultError { + error?.pointee = err + } else { + bubble.pointee = resultBubble + } + } + + @objc public static func receiveFiles(withRequest request: ArkDropReceiveFilesRequest, + bubble: AutoreleasingUnsafeMutablePointer, + error: NSErrorPointer) { + let semaphore = DispatchSemaphore(value: 0) + var resultBubble: ArkDropReceiveFilesBubble? + var resultError: NSError? + + Task { + do { + let swiftRequest = convertToSwiftReceiveRequest(request) + let swiftBubble = try await ArkDrop.receiveFiles(request: swiftRequest) + resultBubble = ArkDropReceiveFilesBubbleImpl(bubble: swiftBubble) + } catch let err as NSError { + resultError = err + } catch { + resultError = NSError(domain: "ArkDropBridge", code: -1, userInfo: [NSLocalizedDescriptionKey: String(describing: error)]) + } + semaphore.signal() + } + + // Wait for async operation to complete + semaphore.wait() + + if let err = resultError { + error?.pointee = err + } else { + bubble.pointee = resultBubble + } + } +} + +// MARK: - Conversion Helpers + +private func convertToSwiftSendRequest(_ request: ArkDropSendFilesRequest) -> SendFilesRequest { + let profile = SenderProfile( + name: request.profile.name, + avatarB64: request.profile.avatarB64 + ) + + let files = request.files.map { file in + SenderFile( + name: file.name, + data: ArkDropSenderFileDataBridge(data: file.data) + ) + } + + let config = request.config.map { c in + SenderConfig( + chunkSize: c.chunkSize, + parallelStreams: c.parallelStreams + ) + } + + return SendFilesRequest( + profile: profile, + files: files, + config: config + ) +} + +private func convertToSwiftReceiveRequest(_ request: ArkDropReceiveFilesRequest) -> ReceiveFilesRequest { + let profile = ReceiverProfile( + name: request.profile.name, + avatarB64: request.profile.avatarB64 + ) + + let config = ReceiverConfig( + chunkSize: request.config.chunkSize, + parallelStreams: request.config.parallelStreams + ) + + return ReceiveFilesRequest( + ticket: request.ticket, + confirmation: request.confirmation, + profile: profile, + config: config + ) +} + +// MARK: - Bridge Implementations + +@objc(ArkDropSendFilesBubbleImpl) public class ArkDropSendFilesBubbleImpl: NSObject, ArkDropSendFilesBubble { + private let bubble: SendFilesBubble + + public init(bubble: SendFilesBubble) { + self.bubble = bubble + super.init() + } + + @objc(getTicket) public func getTicket() -> String { + bubble.getTicket() + } + + @objc(getConfirmation) public func getConfirmation() -> UInt8 { + bubble.getConfirmation() + } + + @objc(cancelWithCompletion:) public func cancel(completion: @escaping ((any Error)?) -> Void) { + Task { + do { + try await bubble.cancel() + completion(nil) + } catch { + completion(error) + } + } + } + + @objc(isFinished) public func isFinished() -> Bool { + bubble.isFinished() + } + + @objc(isConnected) public func isConnected() -> Bool { + bubble.isConnected() + } + + @objc(getCreatedAt) public func getCreatedAt() -> String { + bubble.getCreatedAt() + } + + @objc(subscribeWithSubscriber:) public func subscribe( + with subscriber: ArkDropSendFilesSubscriber + ) { + let swiftSubscriber = ArkDropSendFilesSubscriberBridge(subscriber: subscriber) + bubble.subscribe(subscriber: swiftSubscriber) + } + + @objc(unsubscribeWithSubscriber:) public func unsubscribe( + with subscriber: ArkDropSendFilesSubscriber + ) { + let swiftSubscriber = ArkDropSendFilesSubscriberBridge(subscriber: subscriber) + bubble.unsubscribe(subscriber: swiftSubscriber) + } +} + +@objc(ArkDropReceiveFilesBubbleImpl) public class ArkDropReceiveFilesBubbleImpl: NSObject, ArkDropReceiveFilesBubble { + + private let bubble: ReceiveFilesBubble + + public init(bubble: ReceiveFilesBubble) { + self.bubble = bubble + super.init() + } + + @objc(startWithError:) public func startWithError(_ error: NSErrorPointer) { + do { + try bubble.start() + } catch let err as NSError { + error?.pointee = err + } catch let caughtError { + error?.pointee = NSError( + domain: "ArkDropBridge", + code: -1, + userInfo: [NSLocalizedDescriptionKey: String(describing: caughtError)] + ) + } + } + + @objc public func cancel() { + bubble.cancel() + } + + @objc public func isFinished() -> Bool { + bubble.isFinished() + } + + @objc public func isCancelled() -> Bool { + bubble.isCancelled() + } + + @objc(subscribeWithSubscriber:) public func subscribe( + with subscriber: ArkDropReceiveFilesSubscriber + ) { + let swiftSubscriber = ArkDropReceiveFilesSubscriberBridge(subscriber: subscriber) + bubble.subscribe(subscriber: swiftSubscriber) + } + + @objc(unsubscribeWithSubscriber:) public func unsubscribe( + with subscriber: ArkDropReceiveFilesSubscriber + ) { + let swiftSubscriber = ArkDropReceiveFilesSubscriberBridge(subscriber: subscriber) + bubble.unsubscribe(subscriber: swiftSubscriber) + } +} + +// MARK: - Adapter Classes + +private final class ArkDropSenderFileDataBridge: SenderFileData, @unchecked Sendable { + private let data: ArkDropSenderFileData + + init(data: ArkDropSenderFileData) { + self.data = data + } + + func len() -> UInt64 { + data.len() + } + + func isEmpty() -> Bool { + // Default implementation if not available + return data.len() == 0 + } + + func read() -> UInt8? { + // data.read() returns NSNumber? from Objective-C, convert to UInt8? + if let number = data.read() { + return number.uint8Value + } + return nil + } + + func readChunk(size: Int32) -> Data { + // Bridge sync Objective-C method + return data.readChunk(withSize: size) + } +} + +private final class ArkDropSendFilesSubscriberBridge: SendFilesSubscriber, @unchecked Sendable { + private let subscriber: ArkDropSendFilesSubscriber + + init(subscriber: ArkDropSendFilesSubscriber) { + self.subscriber = subscriber + } + + func getId() -> String { + subscriber.getId() + } + + func log(message: String) { + subscriber.log(withMessage: message) + } + + func notifySending(event: SendFilesSendingEvent) { + subscriber.notifySending(withName: event.name, + sent: event.sent, + remaining: event.remaining) + } + + func notifyConnecting(event: SendFilesConnectingEvent) { + subscriber.notifyConnecting(withReceiverName: event.receiver.name, + receiverAvatarB64: event.receiver.avatarB64) + } +} + +private final class ArkDropReceiveFilesSubscriberBridge: ReceiveFilesSubscriber, @unchecked Sendable { + private let subscriber: ArkDropReceiveFilesSubscriber + + init(subscriber: ArkDropReceiveFilesSubscriber) { + self.subscriber = subscriber + } + + func getId() -> String { + subscriber.getId() + } + + func log(message: String) { + subscriber.log(withMessage: message) + } + + func notifyReceiving(event: ReceiveFilesReceivingEvent) { + subscriber.notifyReceiving(withFileId: event.id, data: event.data) + } + + func notifyConnecting(event: ReceiveFilesConnectingEvent) { + let files = event.files.map { file in + [ + "id": file.id, + "name": file.name, + "len": file.len + ] as [String: Any] + } + subscriber.notifyConnecting(withSenderName: event.sender.name, + senderAvatarB64: event.sender.avatarB64, + files: files) + } +} diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 0000000..5077111 Binary files /dev/null and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json index 2305880..ce8e776 100644 --- a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,6 +1,7 @@ { "images" : [ { + "filename" : "AppIcon.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/iosApp/iosApp/Components/AvatarView.swift b/iosApp/iosApp/Components/AvatarView.swift new file mode 100644 index 0000000..a582ca8 --- /dev/null +++ b/iosApp/iosApp/Components/AvatarView.swift @@ -0,0 +1,40 @@ +import SwiftUI + +struct AvatarView: View { + let avatarBase64: String? + let size: CGFloat + let fallbackIcon: String = "person.circle.fill" + + init(avatarBase64: String?, size: CGFloat = 48) { + self.avatarBase64 = avatarBase64 + self.size = size + } + + var body: some View { + Group { + if let base64 = avatarBase64, + let imageData = Data(base64Encoded: base64), + let uiImage = UIImage(data: imageData) { + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fill) + } else { + Image(systemName: fallbackIcon) + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(.dropPrimary) + } + } + .frame(width: size, height: size) + .clipShape(Circle()) + } +} + +#Preview { + HStack(spacing: Spacing.md) { + AvatarView(avatarBase64: nil, size: 48) + AvatarView(avatarBase64: nil, size: 64) + AvatarView(avatarBase64: nil, size: 32) + } + .padding() +} diff --git a/iosApp/iosApp/Components/DropButton.swift b/iosApp/iosApp/Components/DropButton.swift new file mode 100644 index 0000000..59a3542 --- /dev/null +++ b/iosApp/iosApp/Components/DropButton.swift @@ -0,0 +1,90 @@ +import SwiftUI + +struct DropButton: View { + let title: String + let action: () -> Void + var style: ButtonStyle = .primary + var isEnabled: Bool = true + var isLoading: Bool = false + + enum ButtonStyle { + case primary + case secondary + case outline + case destructive + } + + var body: some View { + Button(action: action) { + HStack(spacing: Spacing.xs) { + if isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: textColor)) + .scaleEffect(0.8) + } + + Text(title) + .font(AppTypography.labelLarge) + } + .frame(maxWidth: .infinity) + .frame(height: 48) + .background(backgroundColor) + .foregroundColor(textColor) + .cornerRadius(CornerRadius.medium) + .overlay( + RoundedRectangle(cornerRadius: CornerRadius.medium) + .stroke(borderColor, lineWidth: borderWidth) + ) + } + .disabled(!isEnabled || isLoading) + .opacity(isEnabled ? 1.0 : 0.5) + } + + private var backgroundColor: Color { + switch style { + case .primary: + return .dropPrimary + case .secondary: + return .dropSecondary + case .outline: + return .clear + case .destructive: + return .dropError + } + } + + private var textColor: Color { + switch style { + case .primary, .destructive: + return .white + case .secondary: + return .black + case .outline: + return .dropPrimary + } + } + + private var borderColor: Color { + switch style { + case .outline: + return .dropPrimary + default: + return .clear + } + } + + private var borderWidth: CGFloat { + style == .outline ? 1.5 : 0 + } +} + +#Preview { + VStack(spacing: Spacing.md) { + DropButton(title: "Primary Button", action: {}, style: .primary) + DropButton(title: "Secondary Button", action: {}, style: .secondary) + DropButton(title: "Outline Button", action: {}, style: .outline) + DropButton(title: "Loading...", action: {}, isLoading: true) + DropButton(title: "Disabled", action: {}, isEnabled: false) + } + .padding() +} diff --git a/iosApp/iosApp/Components/DropCard.swift b/iosApp/iosApp/Components/DropCard.swift new file mode 100644 index 0000000..fdc0d20 --- /dev/null +++ b/iosApp/iosApp/Components/DropCard.swift @@ -0,0 +1,32 @@ +import SwiftUI + +struct DropCard: View { + let content: Content + var padding: CGFloat = Spacing.md + + init(padding: CGFloat = Spacing.md, @ViewBuilder content: () -> Content) { + self.padding = padding + self.content = content() + } + + var body: some View { + content + .padding(padding) + .background(Color.dropCard) + .cornerRadius(CornerRadius.medium) + .shadow(color: Color.black.opacity(0.08), radius: 8, x: 0, y: 2) + } +} + +#Preview { + DropCard { + VStack(alignment: .leading, spacing: Spacing.sm) { + Text("Card Title") + .font(AppTypography.titleLarge) + Text("Card content goes here") + .font(AppTypography.bodyMedium) + .foregroundColor(.secondary) + } + } + .padding() +} diff --git a/iosApp/iosApp/Components/EmptyStateView.swift b/iosApp/iosApp/Components/EmptyStateView.swift new file mode 100644 index 0000000..9ecea86 --- /dev/null +++ b/iosApp/iosApp/Components/EmptyStateView.swift @@ -0,0 +1,49 @@ +import SwiftUI + +struct EmptyStateView: View { + let icon: String + let title: String + let message: String + var action: (() -> Void)? = nil + var actionTitle: String? = nil + + var body: some View { + VStack(spacing: Spacing.lg) { + Image(systemName: icon) + .font(.system(size: 72)) + .foregroundColor(.dropPrimary.opacity(0.6)) + + VStack(spacing: Spacing.xs) { + Text(title) + .font(AppTypography.titleLarge) + .foregroundColor(.primary) + + Text(message) + .font(AppTypography.bodyMedium) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + + if let action = action, let actionTitle = actionTitle { + DropButton( + title: actionTitle, + action: action, + style: .primary + ) + .frame(maxWidth: 280) + } + } + .padding(Spacing.xl) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +#Preview { + EmptyStateView( + icon: "tray", + title: "No Files Yet", + message: "Add files to get started with your first transfer", + action: {}, + actionTitle: "Add Files" + ) +} diff --git a/iosApp/iosApp/Components/ErrorView.swift b/iosApp/iosApp/Components/ErrorView.swift new file mode 100644 index 0000000..2c7b039 --- /dev/null +++ b/iosApp/iosApp/Components/ErrorView.swift @@ -0,0 +1,57 @@ +import SwiftUI + +struct ErrorView: View { + let title: String + let message: String + var onRetry: (() -> Void)? = nil + var onDismiss: (() -> Void)? = nil + + var body: some View { + VStack(spacing: Spacing.lg) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 64)) + .foregroundColor(.dropError) + + VStack(spacing: Spacing.xs) { + Text(title) + .font(AppTypography.titleLarge) + .foregroundColor(.primary) + + Text(message) + .font(AppTypography.bodyMedium) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + + VStack(spacing: Spacing.sm) { + if let onRetry = onRetry { + DropButton( + title: "Try Again", + action: onRetry, + style: .primary + ) + } + + if let onDismiss = onDismiss { + DropButton( + title: "Dismiss", + action: onDismiss, + style: .outline + ) + } + } + .frame(maxWidth: 280) + } + .padding(Spacing.xl) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +#Preview { + ErrorView( + title: "Connection Failed", + message: "Unable to connect to the receiver. Please check your network and try again.", + onRetry: {}, + onDismiss: {} + ) +} diff --git a/iosApp/iosApp/Components/LoadingView.swift b/iosApp/iosApp/Components/LoadingView.swift new file mode 100644 index 0000000..f2b7b2f --- /dev/null +++ b/iosApp/iosApp/Components/LoadingView.swift @@ -0,0 +1,36 @@ +import SwiftUI + +struct LoadingView: View { + let message: String + var canCancel: Bool = false + var onCancel: (() -> Void)? = nil + + var body: some View { + VStack(spacing: Spacing.lg) { + ProgressView() + .scaleEffect(1.5) + .tint(.dropPrimary) + + Text(message) + .font(AppTypography.bodyLarge) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + + if canCancel, let onCancel = onCancel { + DropButton( + title: "Cancel", + action: onCancel, + style: .outline + ) + .frame(maxWidth: 200) + .padding(.top, Spacing.md) + } + } + .padding(Spacing.xl) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +#Preview { + LoadingView(message: "Generating QR Code...", canCancel: true, onCancel: {}) +} diff --git a/iosApp/iosApp/Components/ProgressBar.swift b/iosApp/iosApp/Components/ProgressBar.swift new file mode 100644 index 0000000..19d92f6 --- /dev/null +++ b/iosApp/iosApp/Components/ProgressBar.swift @@ -0,0 +1,38 @@ +import SwiftUI + +struct DropProgressBar: View { + let progress: Double // 0.0 to 1.0 + var height: CGFloat = 8 + var backgroundColor: Color = Color.gray.opacity(0.2) + var foregroundColor: Color = .dropPrimary + + var body: some View { + GeometryReader { geometry in + ZStack(alignment: .leading) { + // Background + RoundedRectangle(cornerRadius: height / 2) + .fill(backgroundColor) + .frame(height: height) + + // Foreground + RoundedRectangle(cornerRadius: height / 2) + .fill(foregroundColor) + .frame( + width: geometry.size.width * min(max(progress, 0), 1), + height: height + ) + .animation(.easeInOut(duration: 0.3), value: progress) + } + } + .frame(height: height) + } +} + +#Preview { + VStack(spacing: Spacing.md) { + DropProgressBar(progress: 0.3) + DropProgressBar(progress: 0.7, foregroundColor: .dropSuccess) + DropProgressBar(progress: 1.0) + } + .padding() +} diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index a206b8a..c6c909f 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -1,28 +1,10 @@ import SwiftUI -import Shared +// Legacy ContentView - keeping for compatibility +// The app now uses AppRootView from iOSApp.swift struct ContentView: View { - @State private var showContent = false var body: some View { - VStack { - Button("Click me!") { - withAnimation { - showContent = !showContent - } - } - - if showContent { - VStack(spacing: 16) { - Image(systemName: "swift") - .font(.system(size: 200)) - .foregroundColor(.accentColor) - Text("SwiftUI: \(Greeting().greet())") - } - .transition(.move(edge: .top).combined(with: .opacity)) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .padding() + AppRootView() } } diff --git a/iosApp/iosApp/Core/AppConfiguration.swift b/iosApp/iosApp/Core/AppConfiguration.swift new file mode 100644 index 0000000..51531ea --- /dev/null +++ b/iosApp/iosApp/Core/AppConfiguration.swift @@ -0,0 +1,27 @@ +import Foundation +import Shared + +/// Handles app-wide configuration and initialization +class AppConfiguration { + static let shared = AppConfiguration() + + private init() {} + + /// Initialize the app - call this on app launch + func initialize() { + initializeKoin() + KoinHelper.shared.logAppStart(platform: "ios") + configureLogging() + } + + private func initializeKoin() { + // Initialize Koin dependency injection + KoinInitializerKt.doInitKoin() + print("✅ Koin initialized successfully") + } + + private func configureLogging() { + // Configure logging if needed + print("✅ App configuration completed") + } +} diff --git a/iosApp/iosApp/Core/DIContainer.swift b/iosApp/iosApp/Core/DIContainer.swift new file mode 100644 index 0000000..cb4dae6 --- /dev/null +++ b/iosApp/iosApp/Core/DIContainer.swift @@ -0,0 +1,36 @@ +import Foundation +import Shared + +/// Dependency Injection Container +/// Provides access to KMP Koin dependencies +@MainActor +class DIContainer { + static let shared = DIContainer() + + private init() {} + + // MARK: - ViewModels + + func makeHomeViewModel() -> HomeViewModel { + return KoinHelper.shared.getHomeViewModel() + } + + func makeSendViewModel() -> SendViewModel { + return KoinHelper.shared.getSendViewModel() + } + + func makeReceiveViewModel() -> ReceiveViewModel { + return KoinHelper.shared.getReceiveViewModel() + } + + func makeEditProfileViewModel() -> EditProfileViewModel { + return KoinHelper.shared.getEditProfileViewModel() + } + + func makeHistoryViewModel() -> HistoryViewModel { + return KoinHelper.shared.getHistoryViewModel() + } +} + +// Note: We'll need to create a KoinHelper.kt in the shared module +// to expose the Koin dependencies to iOS diff --git a/iosApp/iosApp/Core/NavigationCoordinator.swift b/iosApp/iosApp/Core/NavigationCoordinator.swift new file mode 100644 index 0000000..08fe1a2 --- /dev/null +++ b/iosApp/iosApp/Core/NavigationCoordinator.swift @@ -0,0 +1,93 @@ +import SwiftUI +import Shared + +enum Route: Hashable { + case home + case send + case receive + case history + case editProfile + case about +} + +@MainActor +class NavigationCoordinator: ObservableObject { + @Published var path = NavigationPath() + private var routeStack: [Route] = [] + private var lastLoggedRoute: Route? + + init() { + logScreenView(.home) + } + + func navigate(to route: Route) { + routeStack.append(route) + path.append(route) + logScreenView(route) + } + + func navigateBack() { + if !path.isEmpty { + path.removeLast() + } + if !routeStack.isEmpty { + routeStack.removeLast() + } + logScreenView(routeStack.last ?? .home) + } + + func navigateToRoot() { + path.removeLast(path.count) + routeStack.removeAll() + logScreenView(.home) + } + + func replace(with route: Route) { + if !routeStack.isEmpty { + routeStack.removeLast() + } + routeStack.append(route) + path = navigationPath(from: routeStack) + logScreenView(route) + } + + func reconcilePath(count: Int) { + if count < routeStack.count { + routeStack = Array(routeStack.prefix(count)) + } + logScreenView(routeStack.last ?? .home) + } + + private func navigationPath(from routes: [Route]) -> NavigationPath { + var newPath = NavigationPath() + routes.forEach { newPath.append($0) } + return newPath + } + + private func logScreenView(_ route: Route) { + guard lastLoggedRoute != route else { + return + } + lastLoggedRoute = route + KoinHelper.shared.logScreenView(screenName: route.analyticsName) + } +} + +private extension Route { + var analyticsName: String { + switch self { + case .home: + return "home" + case .send: + return "send" + case .receive: + return "receive" + case .history: + return "history" + case .editProfile: + return "edit_profile" + case .about: + return "about" + } + } +} diff --git a/iosApp/iosApp/CrashlyticsBridge.h b/iosApp/iosApp/CrashlyticsBridge.h new file mode 100644 index 0000000..8e3a417 --- /dev/null +++ b/iosApp/iosApp/CrashlyticsBridge.h @@ -0,0 +1,8 @@ +#ifndef CrashlyticsBridge_h +#define CrashlyticsBridge_h + +void crashlytics_recordError(const char *message, const char *stackTrace); +void crashlytics_log(const char *message); +void crashlytics_setCustomKey(const char *key, const char *value); + +#endif diff --git a/iosApp/iosApp/CrashlyticsBridge.m b/iosApp/iosApp/CrashlyticsBridge.m new file mode 100644 index 0000000..047f0ec --- /dev/null +++ b/iosApp/iosApp/CrashlyticsBridge.m @@ -0,0 +1,37 @@ +#import "CrashlyticsBridge.h" +@import FirebaseCore; +@import FirebaseCrashlytics; + +void crashlytics_recordError(const char *message, const char *stackTrace) { + if ([FIRApp defaultApp] == nil) return; + FIRCrashlytics *crashlytics = [FIRCrashlytics crashlytics]; + + NSString *msg = [NSString stringWithUTF8String:message]; + NSMutableDictionary *userInfo = [NSMutableDictionary dictionary]; + userInfo[NSLocalizedDescriptionKey] = msg; + + if (stackTrace != NULL) { + NSString *stack = [NSString stringWithUTF8String:stackTrace]; + userInfo[@"KotlinStackTrace"] = stack; + } + + NSError *error = [NSError errorWithDomain:@"DropKMP" + code:-1 + userInfo:userInfo]; + [crashlytics recordError:error]; +} + +void crashlytics_log(const char *message) { + if ([FIRApp defaultApp] == nil) return; + if (message == NULL) return; + NSString *msg = [NSString stringWithUTF8String:message]; + [[FIRCrashlytics crashlytics] logWithFormat:@"%@", msg]; +} + +void crashlytics_setCustomKey(const char *key, const char *value) { + if ([FIRApp defaultApp] == nil) return; + if (key == NULL) return; + NSString *k = [NSString stringWithUTF8String:key]; + NSString *v = value != NULL ? [NSString stringWithUTF8String:value] : @""; + [[FIRCrashlytics crashlytics] setCustomValue:v forKey:k]; +} diff --git a/iosApp/iosApp/Features/History/HistoryView.swift b/iosApp/iosApp/Features/History/HistoryView.swift new file mode 100644 index 0000000..9dd7426 --- /dev/null +++ b/iosApp/iosApp/Features/History/HistoryView.swift @@ -0,0 +1,149 @@ +import SwiftUI +import Shared + +struct HistoryView: View { + @StateObject private var viewModel = HistoryViewModelWrapper() + + var body: some View { + ZStack { + Color.dropBackground + .ignoresSafeArea() + + if viewModel.state.historyItems.isEmpty { + EmptyStateView( + icon: "clock", + title: "No History", + message: "Your transfer history will appear here" + ) + } else { + ScrollView { + LazyVStack(spacing: Spacing.md) { + ForEach(viewModel.state.historyItems, id: \.id) { item in + HistoryDetailCard(item: item) + } + } + .padding(Spacing.md) + } + } + } + .navigationTitle("Transfer History") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if !viewModel.state.historyItems.isEmpty { + ToolbarItem(placement: .primaryAction) { + Button("Clear All") { + viewModel.onClearHistory() + } + .foregroundColor(.dropError) + } + } + } + } +} + +// MARK: - History Detail Card + +struct HistoryDetailCard: View { + let item: TransferSession + + var body: some View { + DropCard { + VStack(alignment: .leading, spacing: Spacing.md) { + // Header + HStack { + Image(systemName: item.type == .sent ? "arrow.up.circle.fill" : "arrow.down.circle.fill") + .font(.system(size: 40)) + .foregroundColor(item.type == .sent ? .dropSending : .dropReceiving) + + VStack(alignment: .leading, spacing: Spacing.xxs) { + Text(item.type == .sent ? "Sent" : "Received") + .font(AppTypography.titleMedium) + + Text(formatDate(item.timestamp.toEpochMilliseconds())) + .font(AppTypography.bodySmall) + .foregroundColor(.secondary) + } + + Spacer() + } + + Divider() + + // Files + VStack(alignment: .leading, spacing: Spacing.xs) { + Text("Files (\(item.files.count))") + .font(AppTypography.labelMedium) + .foregroundColor(.secondary) + + ForEach(Array(item.files), id: \.name) { file in + HStack { + Image(systemName: "doc") + .font(.system(size: 14)) + .foregroundColor(.secondary) + + Text(file.name) + .font(AppTypography.bodySmall) + .lineLimit(1) + } + } + } + } + } + } + + private func formatDate(_ timestamp: Int64) -> String { + let date = Date(timeIntervalSince1970: TimeInterval(timestamp) / 1000) + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter.string(from: date) + } +} + +// MARK: - ViewModel Wrapper + +@MainActor +class HistoryViewModelWrapper: ObservableObject { + @Published private(set) var state: HistoryScreenState + + private let viewModel: HistoryViewModel + private var stateTask: Task? + + init() { + self.viewModel = DIContainer.shared.makeHistoryViewModel() + self.state = HistoryScreenState( + historyItems: [], + showClearDialog: false, + showDeleteDialog: false + ) + observeState() + } + + private func observeState() { + stateTask = Task { [weak self] in + guard let self = self else { return } + + do { + for try await newState in viewModel.container.stateFlow { + self.state = newState as! HistoryScreenState + } + } catch { + print("HistoryViewModel state error: \(error)") + } + } + } + + func onClearHistory() { + viewModel.onClear() + } + + deinit { + stateTask?.cancel() + } +} + +#Preview { + NavigationStack { + HistoryView() + } +} diff --git a/iosApp/iosApp/Features/Home/HomeView.swift b/iosApp/iosApp/Features/Home/HomeView.swift new file mode 100644 index 0000000..6f877c6 --- /dev/null +++ b/iosApp/iosApp/Features/Home/HomeView.swift @@ -0,0 +1,301 @@ +import SwiftUI +import Shared +import Combine + +struct HomeView: View { + @StateObject private var viewModel: HomeViewModelWrapper + @EnvironmentObject private var coordinator: NavigationCoordinator + + init() { + _viewModel = StateObject(wrappedValue: HomeViewModelWrapper()) + } + + var body: some View { + NavigationStack(path: $coordinator.path) { + ZStack { + Color.dropBackground + .ignoresSafeArea() + + VStack(spacing: 0) { + // Header + headerView + .padding(.horizontal, Spacing.md) + .padding(.vertical, Spacing.md) + + // Main Content + ScrollView { + VStack(spacing: Spacing.lg) { + // Transfer Actions + actionCardsView + .padding(.horizontal, Spacing.md) + + // Recent History + if !viewModel.state.historyItems.isEmpty { + historySection + } + } + .padding(.vertical, Spacing.md) + } + } + } + .navigationTitle("ARK Drop") + .navigationBarTitleDisplayMode(.large) + .navigationDestination(for: Route.self) { route in + destinationView(for: route) + } + .onReceive(viewModel.effectPublisher) { effect in + handleEffect(effect) + } + .onChange(of: coordinator.path.count) { count in + coordinator.reconcilePath(count: count) + } + } + } + + private func handleEffect(_ effect: HomeScreenEffect) { + switch effect { + case is HomeScreenEffect.NavigateToReceiveScreen: + coordinator.navigate(to: .receive) + + case is HomeScreenEffect.AskWritePermission: + // TODO: Request storage permission if needed on iOS + break + + default: + break + } + } + + // MARK: - Header + + private var headerView: some View { + HStack { + VStack(alignment: .leading, spacing: Spacing.xxs) { + Text("Hello, \(viewModel.state.profile.name.isEmpty ? "Anonymous" : viewModel.state.profile.name)") + .font(AppTypography.titleLarge) + + Text("Transfer files securely") + .font(AppTypography.bodySmall) + .foregroundColor(.secondary) + } + + Spacer() + + Button(action: { coordinator.navigate(to: .editProfile) }) { + AvatarView( + avatarBase64: viewModel.state.profile.avatar.base64, + size: 48 + ) + } + } + } + + // MARK: - Action Cards + + private var actionCardsView: some View { + HStack(spacing: Spacing.md) { + ActionCard( + icon: "arrow.up.circle.fill", + title: "Send", + description: "Share files", + color: .dropSending + ) { + coordinator.navigate(to: .send) + } + + ActionCard( + icon: "arrow.down.circle.fill", + title: "Receive", + description: "Get files", + color: .dropReceiving + ) { + viewModel.onReceiveClick() + } + } + } + + // MARK: - History + + private var historySection: some View { + VStack(alignment: .leading, spacing: Spacing.sm) { + HStack { + Text("Recent Transfers") + .font(AppTypography.titleMedium) + .foregroundColor(.primary) + + Spacer() + + Button("See All") { + coordinator.navigate(to: .history) + } + .font(AppTypography.labelMedium) + .foregroundColor(.dropPrimary) + } + .padding(.horizontal, Spacing.md) + + ForEach(Array(viewModel.state.historyItems.prefix(3)), id: \.id) { item in + HistoryItemRow(item: item) + .padding(.horizontal, Spacing.md) + } + } + } + + // MARK: - Navigation + + @ViewBuilder + private func destinationView(for route: Route) -> some View { + switch route { + case .send: + SendView() + case .receive: + ReceiveView() + case .history: + HistoryView() + case .editProfile: + EditProfileView() + case .about: + AboutView() + case .home: + EmptyView() + } + } +} + +// MARK: - Action Card + +struct ActionCard: View { + let icon: String + let title: String + let description: String + let color: Color + let action: () -> Void + + var body: some View { + Button(action: action) { + DropCard(padding: Spacing.lg) { + VStack(spacing: Spacing.sm) { + Image(systemName: icon) + .font(.system(size: 48)) + .foregroundColor(color) + + Text(title) + .font(AppTypography.titleMedium) + .foregroundColor(.primary) + + Text(description) + .font(AppTypography.bodySmall) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity) + } + } + .buttonStyle(PlainButtonStyle()) + } +} + +// MARK: - History Item Row + +struct HistoryItemRow: View { + let item: TransferSession + + var body: some View { + DropCard(padding: Spacing.md) { + HStack(spacing: Spacing.md) { + Image(systemName: item.type == .sent ? "arrow.up.circle.fill" : "arrow.down.circle.fill") + .font(.system(size: 32)) + .foregroundColor(item.type == .sent ? .dropSending : .dropReceiving) + + VStack(alignment: .leading, spacing: Spacing.xxs) { + Text(item.type == .sent ? "Sent" : "Received") + .font(AppTypography.labelMedium) + .foregroundColor(.secondary) + + Text("\(item.files.count) file(s)") + .font(AppTypography.titleSmall) + .foregroundColor(.primary) + + Text(formatDate(item.timestamp.toEpochMilliseconds())) + .font(AppTypography.bodySmall) + .foregroundColor(.secondary) + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.system(size: 14)) + .foregroundColor(.secondary) + } + } + } + + private func formatDate(_ timestamp: Int64) -> String { + let date = Date(timeIntervalSince1970: TimeInterval(timestamp) / 1000) + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .short + return formatter.localizedString(for: date, relativeTo: Date()) + } +} + +// MARK: - ViewModel Wrapper + +@MainActor +class HomeViewModelWrapper: ObservableObject { + @Published private(set) var state: HomeScreenState + private let viewModel: HomeViewModel + private var stateTask: Task? + private var effectTask: Task? + + let effectPublisher = PassthroughSubject() + + init() { + self.viewModel = DIContainer.shared.makeHomeViewModel() + self.state = HomeScreenState( + historyItems: [], + profile: UserProfile.companion.empty() + ) + observeState() + observeEffects() + } + + private func observeState() { + stateTask = Task { [weak self] in + guard let self = self else { return } + + do { + for try await newState in viewModel.container.stateFlow { + self.state = newState as! HomeScreenState + } + } catch { + print("HomeViewModel state error: \(error)") + } + } + } + + private func observeEffects() { + effectTask = Task { [weak self] in + guard let self = self else { return } + + do { + for try await effect in viewModel.container.sideEffectFlow { + self.effectPublisher.send(effect as! HomeScreenEffect) + } + } catch { + print("HomeViewModel effect error: \(error)") + } + } + } + + func onReceiveClick() { + viewModel.onReceiveClick() + } + + deinit { + stateTask?.cancel() + effectTask?.cancel() + } +} + +#Preview { + HomeView() + .environmentObject(NavigationCoordinator()) +} diff --git a/iosApp/iosApp/Features/Profile/AboutView.swift b/iosApp/iosApp/Features/Profile/AboutView.swift new file mode 100644 index 0000000..d7613dd --- /dev/null +++ b/iosApp/iosApp/Features/Profile/AboutView.swift @@ -0,0 +1,106 @@ +import SwiftUI + +struct AboutView: View { + var body: some View { + ZStack { + Color.dropBackground + .ignoresSafeArea() + + ScrollView { + VStack(spacing: Spacing.xl) { + // App Icon + Image(systemName: "arrow.down.circle.fill") + .font(.system(size: 100)) + .foregroundColor(.dropPrimary) + + // App Info + VStack(spacing: Spacing.xs) { + Text("ARK Drop") + .font(AppTypography.headlineMedium) + + Text("Version 1.0.0") + .font(AppTypography.bodyMedium) + .foregroundColor(.secondary) + } + + // Description + DropCard { + VStack(alignment: .leading, spacing: Spacing.sm) { + Text("About") + .font(AppTypography.titleMedium) + + Text("ARK Drop is a secure, privacy-focused file transfer application. Send and receive files directly between devices without cloud storage.") + .font(AppTypography.bodyMedium) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + // Links + VStack(spacing: Spacing.sm) { + LinkRow( + icon: "link", + title: "Website", + url: "https://arkbuilders.github.io" + ) + + LinkRow( + icon: "lock.shield", + title: "Privacy Policy", + url: "https://arkbuilders.github.io/privacy" + ) + + LinkRow( + icon: "doc.text", + title: "Open Source Licenses", + url: "https://github.com/ARK-Builders/Drop-KMP" + ) + } + } + .padding(Spacing.lg) + } + } + .navigationTitle("About") + .navigationBarTitleDisplayMode(.inline) + } +} + +struct LinkRow: View { + let icon: String + let title: String + let url: String + + var body: some View { + Button(action: { + if let url = URL(string: url) { + UIApplication.shared.open(url) + } + }) { + DropCard(padding: Spacing.md) { + HStack { + Image(systemName: icon) + .font(.system(size: 20)) + .foregroundColor(.dropPrimary) + .frame(width: 32) + + Text(title) + .font(AppTypography.bodyMedium) + .foregroundColor(.primary) + + Spacer() + + Image(systemName: "arrow.up.right") + .font(.system(size: 14)) + .foregroundColor(.secondary) + } + } + } + .buttonStyle(PlainButtonStyle()) + } +} + +#Preview { + NavigationStack { + AboutView() + } +} diff --git a/iosApp/iosApp/Features/Profile/EditProfileView.swift b/iosApp/iosApp/Features/Profile/EditProfileView.swift new file mode 100644 index 0000000..fdf0ff9 --- /dev/null +++ b/iosApp/iosApp/Features/Profile/EditProfileView.swift @@ -0,0 +1,229 @@ +import SwiftUI +import Shared +import Combine + +struct EditProfileView: View { + @StateObject private var viewModel = EditProfileViewModelWrapper() + @EnvironmentObject private var coordinator: NavigationCoordinator + @State private var showAvatarPicker = false + + var body: some View { + ZStack { + Color.dropBackground + .ignoresSafeArea() + + ScrollView { + VStack(spacing: Spacing.lg) { + // Avatar Selection + VStack(spacing: Spacing.md) { + AvatarView( + avatarBase64: viewModel.state.avatar.base64, + size: 120 + ) + + DropButton( + title: "Change Avatar", + action: { showAvatarPicker = true }, + style: .outline + ) + .frame(maxWidth: 200) + } + .padding(.vertical, Spacing.lg) + + // Name Input + VStack(alignment: .leading, spacing: Spacing.sm) { + Text("Name") + .font(AppTypography.labelLarge) + .foregroundColor(.secondary) + + TextField("Enter your name", text: Binding( + get: { viewModel.state.name }, + set: { viewModel.onNameChanged($0) } + )) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .font(AppTypography.bodyLarge) + } + + Spacer(minLength: Spacing.xl) + + // Actions + VStack(spacing: Spacing.sm) { + DropButton( + title: "Save Changes", + action: viewModel.onSave, + style: .primary, + isEnabled: viewModel.state.hasChanges + ) + + Button("About") { + coordinator.navigate(to: .about) + } + .font(AppTypography.labelMedium) + .foregroundColor(.dropPrimary) + } + } + .padding(Spacing.lg) + } + } + .navigationTitle("Edit Profile") + .navigationBarTitleDisplayMode(.inline) + .sheet(isPresented: $showAvatarPicker) { + AvatarPickerView( + currentAvatar: viewModel.state.avatar.base64, + onSelect: viewModel.onAvatarSelected + ) + } + .onReceive(viewModel.effectPublisher) { effect in + if effect is EditProfileScreenEffect.NavigateBack { + coordinator.navigateBack() + } + } + } +} + +// MARK: - Avatar Picker + +struct AvatarPickerView: View { + let currentAvatar: String? + let onSelect: (String) -> Void + @Environment(\.dismiss) private var dismiss + + // Available avatar resources (matching Android drawables) + private let avatarIds = ["avatar_00", "avatar_01", "avatar_02", "avatar_03", + "avatar_04", "avatar_05", "avatar_06", "avatar_07", "avatar_08"] + + var body: some View { + NavigationStack { + ScrollView { + LazyVGrid(columns: [ + GridItem(.adaptive(minimum: 80), spacing: Spacing.md) + ], spacing: Spacing.md) { + ForEach(avatarIds, id: \.self) { avatarId in + if let image = UIImage(named: avatarId) { + AvatarGridItem( + image: image, + isSelected: false, + onTap: { + if let base64 = image.pngData()?.base64EncodedString() { + onSelect(base64) + dismiss() + } + } + ) + } + } + } + .padding(Spacing.md) + } + .navigationTitle("Choose Avatar") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + } + } + } + } +} + +struct AvatarGridItem: View { + let image: UIImage + let isSelected: Bool + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 80, height: 80) + .clipShape(Circle()) + .overlay( + Circle() + .stroke(isSelected ? Color.dropPrimary : Color.clear, lineWidth: 3) + ) + } + } +} + +// MARK: - ViewModel Wrapper + +@MainActor +class EditProfileViewModelWrapper: ObservableObject { + @Published private(set) var state: EditProfileScreenState + let effectPublisher = PassthroughSubject() + + private let viewModel: EditProfileViewModel + private var stateTask: Task? + private var effectTask: Task? + + init() { + self.viewModel = DIContainer.shared.makeEditProfileViewModel() + self.state = EditProfileScreenState( + currentProfile: UserProfile.companion.empty(), + name: "", + nameError: nil, + avatar: UserAvatar(base64: "", predefinedId: nil), + avatarImageLoadingFailed: false, + hasChanges: false + ) + observeState() + observeEffects() + } + + private func observeState() { + stateTask = Task { [weak self] in + guard let self = self else { return } + + do { + for try await newState in viewModel.container.stateFlow { + self.state = newState as! EditProfileScreenState + } + } catch { + print("EditProfileViewModel state error: \(error)") + } + } + } + + private func observeEffects() { + effectTask = Task { [weak self] in + guard let self = self else { return } + + do { + for try await effect in viewModel.container.sideEffectFlow { + if let typedEffect = effect as? EditProfileScreenEffect { + self.effectPublisher.send(typedEffect) + } + } + } catch { + print("EditProfileViewModel effect error: \(error)") + } + } + } + + func onNameChanged(_ name: String) { + viewModel.onNameChanged(newName: name) + } + + func onAvatarSelected(_ base64: String) { + viewModel.onAvatarSelected(id: base64) + } + + func onSave() { + viewModel.onSave() + } + + deinit { + stateTask?.cancel() + effectTask?.cancel() + } +} + +#Preview { + NavigationStack { + EditProfileView() + .environmentObject(NavigationCoordinator()) + } +} diff --git a/iosApp/iosApp/Features/Receive/QRScannerView.swift b/iosApp/iosApp/Features/Receive/QRScannerView.swift new file mode 100644 index 0000000..01a546d --- /dev/null +++ b/iosApp/iosApp/Features/Receive/QRScannerView.swift @@ -0,0 +1,207 @@ +import SwiftUI +import AVFoundation +import Shared + +struct QRScannerView: View { + let onCodeScanned: (String, UInt8) -> Void + let onCancel: () -> Void + + @StateObject private var scanner = QRScanner() + + var body: some View { + ZStack { + // Camera Preview + CameraPreview(session: scanner.session) + .ignoresSafeArea() + + // Overlay with scan area + VStack { + Spacer() + + // Scan area indicator + RoundedRectangle(cornerRadius: 20) + .stroke(Color.white, lineWidth: 3) + .frame(width: 280, height: 280) + .overlay( + RoundedRectangle(cornerRadius: 20) + .fill(Color.white.opacity(0.1)) + ) + + Text("Point camera at QR code") + .font(AppTypography.bodyLarge) + .foregroundColor(.white) + .padding(.top, Spacing.lg) + + Spacer() + + // Cancel button + DropButton( + title: "Cancel", + action: onCancel, + style: .outline + ) + .frame(maxWidth: 280) + .padding(.bottom, Spacing.xl) + } + } + .onAppear { + scanner.startScanning() + } + .onDisappear { + scanner.stopScanning() + } + .onChange(of: scanner.scannedCode) { _, newValue in + if let code = newValue { + handleScannedCode(code) + } + } + } + + private func handleScannedCode(_ code: String) { + print("QR Code scanned, length=\(code.count)") + let reporter = KoinHelper.shared.getFirebaseReporter() + reporter.log(message: "QRScanner: code scanned length=\(code.count)") + + // Parse URL format: drop://receive?ticket=ABC123&confirmation=1 + guard let url = URL(string: code), + url.scheme == "drop", + url.host == "receive", + let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let queryItems = components.queryItems else { + print("Invalid QR code format") + reporter.log(message: "QRScanner: invalid QR code format") + return + } + + guard let ticketItem = queryItems.first(where: { $0.name == "ticket" }), + let ticket = ticketItem.value, + let confirmationItem = queryItems.first(where: { $0.name == "confirmation" }), + let confirmationString = confirmationItem.value, + let confirmation = UInt8(confirmationString) else { + print("Missing transfer code in QR code") + reporter.recordError(message: "QRScanner: missing transfer code in QR code", throwable: nil) + return + } + + print("Parsed QR") + reporter.log(message: "QRScanner: parsed QR code") + onCodeScanned(ticket, confirmation) + scanner.stopScanning() + } +} + +// MARK: - Camera Preview + +struct CameraPreview: UIViewRepresentable { + let session: AVCaptureSession + + func makeUIView(context: Context) -> UIView { + let view = UIView(frame: .zero) + view.backgroundColor = .black + + let previewLayer = AVCaptureVideoPreviewLayer(session: session) + previewLayer.videoGravity = .resizeAspectFill + view.layer.addSublayer(previewLayer) + + context.coordinator.previewLayer = previewLayer + + return view + } + + func updateUIView(_ uiView: UIView, context: Context) { + DispatchQueue.main.async { + context.coordinator.previewLayer?.frame = uiView.bounds + } + } + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + class Coordinator { + var previewLayer: AVCaptureVideoPreviewLayer? + } +} + +// MARK: - QR Scanner + +@MainActor +class QRScanner: NSObject, ObservableObject, AVCaptureMetadataOutputObjectsDelegate { + @Published var scannedCode: String? + + let session = AVCaptureSession() + private let metadataOutput = AVCaptureMetadataOutput() + private let sessionQueue = DispatchQueue(label: "qr.scanner.session") + + override init() { + super.init() + setupCamera() + } + + private func setupCamera() { + sessionQueue.async { [weak self] in + guard let self = self else { return } + + guard let videoCaptureDevice = AVCaptureDevice.default(for: .video) else { + let reporter = KoinHelper.shared.getFirebaseReporter() + reporter.recordError(message: "QRScanner: no video capture device available", throwable: nil) + return + } + + guard let videoInput = try? AVCaptureDeviceInput(device: videoCaptureDevice) else { + let reporter = KoinHelper.shared.getFirebaseReporter() + reporter.recordError(message: "QRScanner: failed to create video input", throwable: nil) + return + } + + if self.session.canAddInput(videoInput) { + self.session.addInput(videoInput) + } + + if self.session.canAddOutput(self.metadataOutput) { + self.session.addOutput(self.metadataOutput) + + self.metadataOutput.setMetadataObjectsDelegate(self, queue: DispatchQueue.main) + self.metadataOutput.metadataObjectTypes = [.qr] + } + } + } + + func startScanning() { + sessionQueue.async { [weak self] in + let reporter = KoinHelper.shared.getFirebaseReporter() + reporter.log(message: "QRScanner: start scanning") + self?.session.startRunning() + } + } + + func stopScanning() { + sessionQueue.async { [weak self] in + let reporter = KoinHelper.shared.getFirebaseReporter() + reporter.log(message: "QRScanner: stop scanning") + self?.session.stopRunning() + } + } + + nonisolated func metadataOutput( + _ output: AVCaptureMetadataOutput, + didOutput metadataObjects: [AVMetadataObject], + from connection: AVCaptureConnection + ) { + if let metadataObject = metadataObjects.first as? AVMetadataMachineReadableCodeObject, + let stringValue = metadataObject.stringValue { + Task { @MainActor in + self.scannedCode = stringValue + } + } + } +} + +#Preview { + QRScannerView( + onCodeScanned: { ticket, conf in + print("Scanned QR code") + }, + onCancel: {} + ) +} diff --git a/iosApp/iosApp/Features/Receive/ReceiveView.swift b/iosApp/iosApp/Features/Receive/ReceiveView.swift new file mode 100644 index 0000000..1d5a855 --- /dev/null +++ b/iosApp/iosApp/Features/Receive/ReceiveView.swift @@ -0,0 +1,717 @@ +import SwiftUI +import Shared +import AVFoundation +import Combine + +struct ReceiveView: View { + @StateObject private var viewModel = ReceiveViewModelWrapper() + @EnvironmentObject private var coordinator: NavigationCoordinator + + private let reporter = KoinHelper.shared.getFirebaseReporter() + + var body: some View { + ZStack { + Color.dropBackground + .ignoresSafeArea() + + Group { + switch viewModel.state { + case let state as ReceiveScreenState.Initial: + InitialReceiveView( + hasCameraPermission: state.cameraPermissionGranted, + onStartScanning: viewModel.onStartScanning, + onEnterManually: viewModel.onEnterManually, + onRequestPermission: viewModel.onRequestCameraPermission + ) + .onAppear { + reporter.log(message: "ReceiveView: state=Initial cameraGranted=\(state.cameraPermissionGranted)") + } + + case is ReceiveScreenState.RequestingPermission: + LoadingView(message: "Requesting camera permission...") + .onAppear { + reporter.log(message: "ReceiveView: state=RequestingPermission") + } + + case is ReceiveScreenState.Scanning: + QRScannerView( + onCodeScanned: viewModel.onQrCodeScanned, + onCancel: viewModel.onStopScanning + ) + .onAppear { + reporter.log(message: "ReceiveView: state=Scanning") + } + + case let state as ReceiveScreenState.ManualInput: + ManualInputView( + inputText: state.inputText, + inputError: state.inputError, + onInputChanged: viewModel.onManualInputChanged, + onSubmit: viewModel.handleManualInputSubmit, + onCancel: viewModel.onCancelManualInput, + onPaste: viewModel.onPasteFromClipboard + ) + .onAppear { + reporter.log(message: "ReceiveView: state=ManualInput") + } + + case let state as ReceiveScreenState.QRCodeScanned: + QRScannedView( + ticket: state.ticket, + confirmation: state.confirmation, + onAccept: viewModel.onAccept, + onCancel: viewModel.onScanAgain + ) + .onAppear { + reporter.log(message: "ReceiveView: state=QRCodeScanned") + } + + case is ReceiveScreenState.Connecting: + LoadingView(message: "Connecting to sender...") + .onAppear { + reporter.log(message: "ReceiveView: state=Connecting") + } + + case let state as ReceiveScreenState.Receiving: + ReceivingView( + state: state, + onCancel: viewModel.onCancelReceiving + ) + + case let state as ReceiveScreenState.Success: + ReceiveSuccessView( + fileCount: state.receivedFiles.count, + onReceiveMore: viewModel.onReceiveMore, + onDone: viewModel.onDone + ) + .onAppear { + reporter.log(message: "ReceiveView: state=Success files=\(state.receivedFiles.count)") + } + + case let state as ReceiveScreenState.Error: + ReceiveErrorView( + error: state.error, + onRetry: viewModel.onErrorRetry, + onDismiss: viewModel.onErrorDismiss + ) + .onAppear { + reporter.recordError(message: "ReceiveView: state=Error error=\(state.error.name)", throwable: nil) + } + + default: + EmptyView() + } + } + } + .navigationTitle("Receive Files") + .navigationBarTitleDisplayMode(.inline) + .onReceive(viewModel.effectPublisher) { effect in + handleEffect(effect) + } + } + + private func handleEffect(_ effect: ReceiveScreenEffect) { + switch effect { + case is ReceiveScreenEffect.RequestCameraPermission: + reporter.log(message: "ReceiveView: requesting camera permission") + requestCameraPermission() + + case is ReceiveScreenEffect.NavigateBack: + reporter.log(message: "ReceiveView: navigating back") + coordinator.navigateBack() + + case is ReceiveScreenEffect.HideKeyboard: + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + + default: + break + } + } + + private func requestCameraPermission() { + reporter.log(message: "ReceiveView: AVCaptureDevice.requestAccess for video") + AVCaptureDevice.requestAccess(for: .video) { granted in + Task { @MainActor in + self.reporter.log(message: "ReceiveView: camera permission result=\(granted)") + viewModel.onCameraPermissionGranted(granted) + } + } + } +} + +// MARK: - Initial View + +struct InitialReceiveView: View { + let hasCameraPermission: Bool + let onStartScanning: () -> Void + let onEnterManually: () -> Void + let onRequestPermission: () -> Void + + var body: some View { + VStack(spacing: Spacing.xl) { + Image(systemName: "qrcode.viewfinder") + .font(.system(size: 100)) + .foregroundColor(.dropPrimary.opacity(0.6)) + + VStack(spacing: Spacing.sm) { + Text("Receive Files") + .font(AppTypography.titleLarge) + + Text("Scan a QR code from the sender to receive files") + .font(AppTypography.bodyMedium) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + + VStack(spacing: Spacing.sm) { + if hasCameraPermission { + DropButton( + title: "Scan QR Code", + action: onStartScanning, + style: .primary + ) + } else { + DropButton( + title: "Allow Camera Access", + action: onRequestPermission, + style: .primary + ) + } + + DropButton( + title: "Enter Code Manually", + action: onEnterManually, + style: .outline + ) + } + .frame(maxWidth: 280) + } + .padding(Spacing.xl) + } +} + +// MARK: - Manual Input + +struct ManualInputView: View { + let inputText: String + let inputError: String? + let onInputChanged: (String) -> Void + let onSubmit: () -> Void + let onCancel: () -> Void + let onPaste: (String?) -> Void + + @FocusState private var isFocused: Bool + + var body: some View { + VStack(spacing: Spacing.lg) { + VStack(alignment: .leading, spacing: Spacing.sm) { + Text("Enter Transfer Code") + .font(AppTypography.titleMedium) + + Text("Format: ticket confirmation") + .font(AppTypography.bodySmall) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + + VStack(alignment: .leading, spacing: Spacing.xs) { + TextField("e.g. abc123 42", text: Binding( + get: { inputText }, + set: { onInputChanged($0) } + )) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .font(AppTypography.bodyMedium.monospaced()) + .focused($isFocused) + .onSubmit(onSubmit) + + if let error = inputError { + Text(error) + .font(AppTypography.bodySmall) + .foregroundColor(.dropError) + } + } + + Button(action: { + onPaste(UIPasteboard.general.string) + }) { + HStack { + Image(systemName: "doc.on.clipboard") + Text("Paste from Clipboard") + } + .font(AppTypography.labelMedium) + .foregroundColor(.dropPrimary) + } + + Spacer() + + VStack(spacing: Spacing.sm) { + DropButton( + title: "Submit", + action: onSubmit, + style: .primary, + isEnabled: !inputText.isEmpty + ) + + DropButton( + title: "Cancel", + action: onCancel, + style: .outline + ) + } + .frame(maxWidth: 280) + } + .padding(Spacing.lg) + .onAppear { + isFocused = true + } + } +} + +// MARK: - QR Scanned + +struct QRScannedView: View { + let ticket: String + let confirmation: UInt8 + let onAccept: () -> Void + let onCancel: () -> Void + + private let reporter = KoinHelper.shared.getFirebaseReporter() + + var body: some View { + VStack(spacing: Spacing.xl) { + Spacer() + + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 80)) + .foregroundColor(.dropSuccess) + + VStack(spacing: Spacing.sm) { + Text("Code Scanned") + .font(AppTypography.titleLarge) + + VStack(spacing: Spacing.xxs) { + Text("Ticket: \(ticket)") + .font(AppTypography.bodyMedium.monospaced()) + Text("Confirmation: \(confirmation)") + .font(AppTypography.bodyMedium.monospaced()) + } + .foregroundColor(.secondary) + } + + Spacer() + + VStack(spacing: Spacing.sm) { + DropButton( + title: "Accept Transfer", + action: { + reporter.log(message: "QRScannedView: accept tapped") + onAccept() + }, + style: .primary + ) + + DropButton( + title: "Scan Again", + action: { + reporter.log(message: "QRScannedView: scan again tapped") + onCancel() + }, + style: .outline + ) + } + .frame(maxWidth: 280) + } + .padding(Spacing.lg) + } +} + +// MARK: - Receiving + +struct ReceivingView: View { + let state: ReceiveScreenState.Receiving + let onCancel: () -> Void + + private let reporter = KoinHelper.shared.getFirebaseReporter() + + var body: some View { + VStack(spacing: Spacing.xl) { + Spacer() + + // Sender Info + if state.progress.isConnected { + VStack(spacing: Spacing.md) { + AvatarView( + avatarBase64: state.progress.senderAvatar, + size: 80 + ) + + Text("Receiving from \(state.progress.senderName)") + .font(AppTypography.titleLarge) + } + .onAppear { + reporter.log(message: "ReceivingView: connected to sender files=\(state.progress.files.count)") + } + } + + // Files Progress + if !state.progress.files.isEmpty { + VStack(spacing: Spacing.md) { + ForEach(state.progress.files, id: \.id) { file in + let receivedBytes = state.progress.fileProgress[file.id]?.receivedBytes ?? 0 + let isComplete = state.progress.fileProgress[file.id]?.isComplete ?? false + FileProgressRow( + fileName: file.name, + totalSize: Int64(file.size), + receivedBytes: receivedBytes, + isComplete: isComplete + ) + .onAppear { + reporter.log(message: "ReceivingView: file size=\(file.size) received=\(receivedBytes) complete=\(isComplete)") + } + } + } + .padding(.horizontal, Spacing.lg) + } else { + ProgressView() + .scaleEffect(1.5) + .tint(.dropReceiving) + + Text("Waiting for files...") + .font(AppTypography.bodyMedium) + .foregroundColor(.secondary) + } + + Spacer() + + DropButton( + title: "Cancel", + action: { + reporter.log(message: "ReceivingView: cancel tapped") + onCancel() + }, + style: .destructive + ) + .frame(maxWidth: 280) + } + .padding(Spacing.lg) + } +} + +// MARK: - File Progress Row + +struct FileProgressRow: View { + let fileName: String + let totalSize: Int64 + let receivedBytes: Int64 + let isComplete: Bool + + var body: some View { + VStack(alignment: .leading, spacing: Spacing.xs) { + HStack { + Text(fileName) + .font(AppTypography.bodyMedium) + .lineLimit(1) + + Spacer() + + if isComplete { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.dropSuccess) + } + } + + DropProgressBar( + progress: Double(receivedBytes) / Double(max(totalSize, 1)), + foregroundColor: .dropReceiving + ) + + HStack { + Text(formatBytes(receivedBytes)) + .font(AppTypography.bodySmall) + + Spacer() + + Text(formatBytes(totalSize)) + .font(AppTypography.bodySmall) + } + .foregroundColor(.secondary) + } + .padding(Spacing.sm) + .background(Color.dropCard) + .cornerRadius(CornerRadius.small) + } + + private func formatBytes(_ bytes: Int64) -> String { + ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file) + } +} + +// MARK: - Success View + +struct ReceiveSuccessView: View { + let fileCount: Int + let onReceiveMore: () -> Void + let onDone: () -> Void + + private let reporter = KoinHelper.shared.getFirebaseReporter() + + var body: some View { + VStack(spacing: Spacing.xl) { + Spacer() + + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 80)) + .foregroundColor(.dropSuccess) + + VStack(spacing: Spacing.sm) { + Text("Files Received!") + .font(AppTypography.titleLarge) + + Text("\(fileCount) file(s) saved successfully") + .font(AppTypography.bodyMedium) + .foregroundColor(.secondary) + } + + Spacer() + + VStack(spacing: Spacing.sm) { + DropButton( + title: "Receive More Files", + action: { + reporter.log(message: "ReceiveSuccessView: receive more tapped") + onReceiveMore() + }, + style: .primary + ) + + DropButton( + title: "Done", + action: { + reporter.log(message: "ReceiveSuccessView: done tapped") + onDone() + }, + style: .outline + ) + } + .frame(maxWidth: 280) + } + .onAppear { + reporter.log(message: "ReceiveSuccessView: displayed fileCount=\(fileCount)") + } + .padding(Spacing.lg) + } +} + +// MARK: - Error View + +struct ReceiveErrorView: View { + let error: ReceiveError + let onRetry: () -> Void + let onDismiss: () -> Void + + private let reporter = KoinHelper.shared.getFirebaseReporter() + + var body: some View { + ErrorView( + title: errorTitle, + message: errorMessage, + onRetry: { + reporter.log(message: "ReceiveErrorView: retry tapped error=\(error.name)") + onRetry() + }, + onDismiss: { + reporter.log(message: "ReceiveErrorView: dismiss tapped error=\(error.name)") + onDismiss() + } + ) + .onAppear { + reporter.recordError(message: "ReceiveErrorView: displayed error=\(error.name)", throwable: nil) + } + } + + private var errorTitle: String { + switch error { + case .cameraPermissionDenied: + return "Camera Permission Required" + case .connectionFailed: + return "Connection Failed" + case .networkError: + return "Network Error" + case .storageError: + return "Storage Error" + default: + return "Error" + } + } + + private var errorMessage: String { + switch error { + case .cameraPermissionDenied: + return "Camera access is needed to scan QR codes. Please enable it in Settings." + case .connectionFailed: + return "Unable to connect to the sender. Please try again." + case .networkError: + return "Network connection lost. Please check your connection." + case .storageError: + return "Unable to save files. Please check your storage space." + default: + return "An error occurred. Please try again." + } + } +} + +// MARK: - ViewModel Wrapper + +@MainActor +class ReceiveViewModelWrapper: ObservableObject { + @Published private(set) var state: ReceiveScreenState + let effectPublisher = PassthroughSubject() + + private let viewModel: ReceiveViewModel + private let reporter = KoinHelper.shared.getFirebaseReporter() + private var stateTask: Task? + private var effectTask: Task? + + init() { + self.viewModel = DIContainer.shared.makeReceiveViewModel() + self.state = ReceiveScreenState.Initial(cameraPermissionGranted: false) + reporter.log(message: "ReceiveViewModelWrapper: initialized") + observeState() + observeEffects() + } + + private func observeState() { + stateTask = Task { [weak self] in + guard let self = self else { return } + + do { + for try await newState in viewModel.container.stateFlow { + let stateType = "\(type(of: newState))" + print("📡 ReceiveViewModel state changed: \(stateType)") + self.reporter.log(message: "ReceiveViewModelWrapper: state changed to \(stateType)") + self.state = newState as! ReceiveScreenState + } + } catch { + print("❌ ReceiveViewModel state error: \(error)") + self.reporter.recordError(message: "ReceiveViewModelWrapper: state observation error", throwable: error as? KotlinThrowable) + } + } + } + + private func observeEffects() { + effectTask = Task { [weak self] in + guard let self = self else { return } + + do { + for try await effect in viewModel.container.sideEffectFlow { + if let typedEffect = effect as? ReceiveScreenEffect { + self.effectPublisher.send(typedEffect) + } + } + } catch { + print("ReceiveViewModel effect error: \(error)") + self.reporter.recordError(message: "ReceiveViewModelWrapper: effect observation error", throwable: error as? KotlinThrowable) + } + } + } + + func onStartScanning() { + reporter.log(message: "ReceiveViewModelWrapper: onStartScanning") + viewModel.onStartScanning() + } + + func onStopScanning() { + reporter.log(message: "ReceiveViewModelWrapper: onStopScanning") + viewModel.onStopScanning() + } + + func onEnterManually() { + reporter.log(message: "ReceiveViewModelWrapper: onEnterManually") + viewModel.onEnterManually() + } + + func onRequestCameraPermission() { + reporter.log(message: "ReceiveViewModelWrapper: onRequestCameraPermission") + viewModel.onRequestCameraPermission() + } + + func onCameraPermissionGranted(_ granted: Bool) { + reporter.log(message: "ReceiveViewModelWrapper: onCameraPermissionGranted granted=\(granted)") + viewModel.onCameraPermissionGranted(isGranted: granted) + } + + func onQrCodeScanned(ticket: String, confirmation: UInt8) { + print("ReceiveViewModelWrapper.onQrCodeScanned") + reporter.log(message: "ReceiveViewModelWrapper: onQrCodeScanned") + viewModel.onQrCodeScanned(ticket: ticket, confirmation: confirmation) + print("Called viewModel.onQrCodeScanned") + } + + func onManualInputChanged(_ input: String) { + viewModel.onManualInputChanged(input: input) + } + + func handleManualInputSubmit() { + reporter.log(message: "ReceiveViewModelWrapper: handleManualInputSubmit") + viewModel.handleManualInputSubmit() + } + + func onPasteFromClipboard(_ text: String?) { + let hasText = text != nil ? "yes" : "no" + reporter.log(message: "ReceiveViewModelWrapper: onPasteFromClipboard hasText=\(hasText)") + viewModel.onPasteFromClipboard(clipText: text) + } + + func onAccept() { + reporter.log(message: "ReceiveViewModelWrapper: onAccept") + viewModel.onAccept() + } + + func onCancelReceiving() { + reporter.log(message: "ReceiveViewModelWrapper: onCancelReceiving") + viewModel.onCancelReceiving() + } + + func onCancelManualInput() { + reporter.log(message: "ReceiveViewModelWrapper: onCancelManualInput") + viewModel.onCancelManualInput() + } + + func onScanAgain() { + reporter.log(message: "ReceiveViewModelWrapper: onScanAgain") + viewModel.onScanAgain() + } + + func onReceiveMore() { + reporter.log(message: "ReceiveViewModelWrapper: onReceiveMore") + viewModel.onReceiveMore() + } + + func onDone() { + reporter.log(message: "ReceiveViewModelWrapper: onDone") + viewModel.onDone() + } + + func onErrorRetry() { + reporter.log(message: "ReceiveViewModelWrapper: onErrorRetry") + viewModel.onErrorRetry() + } + + func onErrorDismiss() { + reporter.log(message: "ReceiveViewModelWrapper: onErrorDismiss") + viewModel.onErrorDismiss() + } + + deinit { + reporter.log(message: "ReceiveViewModelWrapper: deinit") + stateTask?.cancel() + effectTask?.cancel() + } +} + +#Preview { + NavigationStack { + ReceiveView() + .environmentObject(NavigationCoordinator()) + } +} diff --git a/iosApp/iosApp/Features/Send/SendView.swift b/iosApp/iosApp/Features/Send/SendView.swift new file mode 100644 index 0000000..c319c8b --- /dev/null +++ b/iosApp/iosApp/Features/Send/SendView.swift @@ -0,0 +1,624 @@ +import SwiftUI +import Shared +import UniformTypeIdentifiers +import Combine + +struct SendView: View { + @StateObject private var viewModel = SendViewModelWrapper() + @EnvironmentObject private var coordinator: NavigationCoordinator + @State private var showFilePicker = false + private let reporter = KoinHelper.shared.getFirebaseReporter() + + var body: some View { + ZStack { + Color.dropBackground + .ignoresSafeArea() + + Group { + switch viewModel.state { + case let state as SendScreenState.FileSelection: + FileSelectionView( + state: state, + onAddFiles: { showFilePicker = true }, + onRemoveFile: viewModel.onFileRemove, + onStartTransfer: viewModel.onStartTransfer + ) + .onAppear { + reporter.log(message: "SendView: state=FileSelection files=\(state.files.count) canStart=\(state.canStartTransfer)") + } + + case let state as SendScreenState.GeneratingQR: + LoadingView( + message: "Generating QR Code...", + canCancel: true, + onCancel: viewModel.onCancelQrGeneration + ) + .onAppear { + reporter.log(message: "SendView: state=GeneratingQR files=\(state.files.count)") + } + + case let state as SendScreenState.WaitingForReceiver: + WaitingForReceiverView( + state: state, + onCancel: viewModel.onCancelTransfer + ) + .onAppear { + reporter.log(message: "SendView: state=WaitingForReceiver") + } + + case let state as SendScreenState.Transfer: + TransferringView( + state: state, + onCancel: viewModel.onCancelTransfer + ) + .onAppear { + reporter.log(message: "SendView: state=Transfer progress=\(state.bytesTransferred)/\(state.totalBytes)") + } + + case let state as SendScreenState.Complete: + TransferCompleteView( + fileCount: state.files.count, + onSendMore: viewModel.onSendMore, + onDone: viewModel.onDone + ) + .onAppear { + reporter.log(message: "SendView: state=Complete files=\(state.files.count)") + } + + case let state as SendScreenState.Error: + SendErrorView( + error: state.error, + onRetry: viewModel.onErrorRetry, + onDismiss: viewModel.onErrorDismiss + ) + .onAppear { + reporter.recordError(message: "SendView: state=Error error=\(state.error.name)", throwable: nil) + } + + default: + EmptyView() + } + } + } + .navigationTitle("Send Files") + .navigationBarTitleDisplayMode(.inline) + .fileImporter( + isPresented: $showFilePicker, + allowedContentTypes: [.item], + allowsMultipleSelection: true + ) { result in + switch result { + case .success(let urls): + // Start accessing security-scoped resources and use file paths + reporter.log(message: "SendView: file picker returned \(urls.count) URLs") + var accessiblePaths: [String] = [] + for url in urls { + if url.startAccessingSecurityScopedResource() { + accessiblePaths.append(url.path) + viewModel.trackAccessedURL(url) + reporter.log(message: "SendView: accessed file size=\(url.fileSize) bytes") + } else { + reporter.log(message: "SendView: failed to access security-scoped resource") + } + } + reporter.log(message: "SendView: added \(accessiblePaths.count) accessible files to viewModel") + viewModel.onFilesAdded(accessiblePaths) + case .failure(let error): + reporter.recordError(message: "SendView: file picker error - \(error.localizedDescription)", throwable: nil) + print("File picker error: \(error)") + } + } + .onReceive(viewModel.effectPublisher) { effect in + handleEffect(effect) + } + } + + private func handleEffect(_ effect: SendScreenEffect) { + switch effect { + case is SendScreenEffect.LaunchFilePicker: + showFilePicker = true + + case is SendScreenEffect.NavigateBack: + coordinator.navigateBack() + + default: + break + } + } +} + +// MARK: - File Selection View + +struct FileSelectionView: View { + let state: SendScreenState.FileSelection + let onAddFiles: () -> Void + let onRemoveFile: (String) -> Void + let onStartTransfer: () -> Void + + var body: some View { + VStack(spacing: Spacing.lg) { + if state.files.isEmpty { + EmptyStateView( + icon: "doc.badge.plus", + title: "No Files Selected", + message: "Choose files to send to another device", + action: onAddFiles, + actionTitle: "Add Files" + ) + } else { + ScrollView { + VStack(spacing: Spacing.md) { + // File List + ForEach(state.files, id: \.self) { file in + FileItemRow( + fileName: extractFileName(from: file), + fileSize: getFileSize(for: file), + onRemove: { onRemoveFile(file) } + ) + } + .padding(.horizontal, Spacing.md) + + // Actions + VStack(spacing: Spacing.sm) { + DropButton( + title: "Add More Files", + action: onAddFiles, + style: .outline + ) + + DropButton( + title: "Start Transfer", + action: onStartTransfer, + style: .primary, + isEnabled: state.canStartTransfer + ) + } + .padding(.horizontal, Spacing.md) + .padding(.top, Spacing.md) + } + } + } + } + .padding(.vertical, Spacing.md) + } + + private func extractFileName(from path: String) -> String { + URL(fileURLWithPath: path).lastPathComponent + } + + private func getFileSize(for path: String) -> Int64 { + let url = URL(fileURLWithPath: path) + do { + let resources = try url.resourceValues(forKeys: [.fileSizeKey]) + return Int64(resources.fileSize ?? 0) + } catch { + return 0 + } + } +} + +// MARK: - File Item Row + +struct FileItemRow: View { + let fileName: String + let fileSize: Int64 + let onRemove: () -> Void + + var body: some View { + DropCard(padding: Spacing.md) { + HStack(spacing: Spacing.md) { + Image(systemName: fileIcon) + .font(.system(size: 32)) + .foregroundColor(.dropPrimary) + .frame(width: 40) + + VStack(alignment: .leading, spacing: Spacing.xxs) { + Text(fileName) + .font(AppTypography.bodyMedium) + .foregroundColor(.primary) + .lineLimit(2) + + if fileSize > 0 { + Text(formatFileSize(fileSize)) + .font(AppTypography.bodySmall) + .foregroundColor(.secondary) + } + } + + Spacer() + + Button(action: onRemove) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 24)) + .foregroundColor(.secondary) + } + } + } + } + + private var fileIcon: String { + let ext = (fileName as NSString).pathExtension.lowercased() + switch ext { + case "jpg", "jpeg", "png", "gif", "heic": + return "photo" + case "mp4", "mov", "avi": + return "video" + case "mp3", "wav", "m4a": + return "music.note" + case "pdf": + return "doc.text" + case "zip", "rar": + return "doc.zipper" + default: + return "doc" + } + } + + private func formatFileSize(_ size: Int64) -> String { + ByteCountFormatter.string(fromByteCount: size, countStyle: .file) + } +} + +// MARK: - Waiting For Receiver + +struct WaitingForReceiverView: View { + let state: SendScreenState.WaitingForReceiver + let onCancel: () -> Void + + var body: some View { + VStack(spacing: Spacing.xl) { + Spacer() + + // QR Code + if let qrImage = imageFromBytes(state.qrBitmap) { + Image(uiImage: qrImage) + .interpolation(.none) + .resizable() + .scaledToFit() + .frame(width: 280, height: 280) + .background(Color.white) + .cornerRadius(CornerRadius.large) + .shadow(color: Color.black.opacity(0.1), radius: 12, x: 0, y: 4) + } + + VStack(spacing: Spacing.sm) { + Text("Scan this QR code") + .font(AppTypography.titleLarge) + + Text("Waiting for receiver to connect...") + .font(AppTypography.bodyMedium) + .foregroundColor(.secondary) + + // Copy ticket/confirmation + HStack { + Text(state.copyString) + .font(AppTypography.bodySmall.monospaced()) + .foregroundColor(.secondary) + + Button(action: { + UIPasteboard.general.string = state.copyString + KoinHelper.shared.logSendCodeCopied() + }) { + Image(systemName: "doc.on.doc") + .foregroundColor(.dropPrimary) + } + } + .padding(.horizontal, Spacing.md) + .padding(.vertical, Spacing.sm) + .background(Color.gray.opacity(0.1)) + .cornerRadius(CornerRadius.small) + } + + Spacer() + + DropButton( + title: "Cancel", + action: onCancel, + style: .outline + ) + .frame(maxWidth: 280) + } + .padding(Spacing.lg) + } + + private func imageFromBytes(_ bytes: KotlinByteArray) -> UIImage? { + let count = Int(bytes.size) + guard count > 0 else { + print("⚠️ QR code data is empty") + return nil + } + + var byteArray = [UInt8](repeating: 0, count: count) + for i in 0.. Void + + var body: some View { + VStack(spacing: Spacing.xl) { + Spacer() + + // Receiver Info + VStack(spacing: Spacing.md) { + AvatarView( + avatarBase64: state.receiverAvatar, + size: 80 + ) + + Text("Sending to \(state.receiverName)") + .font(AppTypography.titleLarge) + + Text(state.currentFileName) + .font(AppTypography.bodyMedium) + .foregroundColor(.secondary) + .lineLimit(1) + } + + // Progress + VStack(spacing: Spacing.md) { + DropProgressBar( + progress: Double(state.bytesTransferred) / Double(max(state.totalBytes, 1)), + height: 12, + foregroundColor: .dropSending + ) + + HStack { + Text(formatBytes(state.bytesTransferred)) + .font(AppTypography.bodySmall) + + Spacer() + + Text(formatBytes(state.totalBytes)) + .font(AppTypography.bodySmall) + } + .foregroundColor(.secondary) + } + .padding(.horizontal, Spacing.xl) + + Spacer() + + DropButton( + title: "Cancel Transfer", + action: onCancel, + style: .destructive + ) + .frame(maxWidth: 280) + } + .padding(Spacing.lg) + } + + private func formatBytes(_ bytes: Int64) -> String { + ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file) + } +} + +// MARK: - Transfer Complete + +struct TransferCompleteView: View { + let fileCount: Int + let onSendMore: () -> Void + let onDone: () -> Void + + var body: some View { + VStack(spacing: Spacing.xl) { + Spacer() + + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 80)) + .foregroundColor(.dropSuccess) + + VStack(spacing: Spacing.sm) { + Text("Transfer Complete!") + .font(AppTypography.titleLarge) + + Text("\(fileCount) file(s) sent successfully") + .font(AppTypography.bodyMedium) + .foregroundColor(.secondary) + } + + Spacer() + + VStack(spacing: Spacing.sm) { + DropButton( + title: "Send More Files", + action: onSendMore, + style: .primary + ) + + DropButton( + title: "Done", + action: onDone, + style: .outline + ) + } + .frame(maxWidth: 280) + } + .padding(Spacing.lg) + } +} + +// MARK: - Error View + +struct SendErrorView: View { + let error: SendException + let onRetry: () -> Void + let onDismiss: () -> Void + + var body: some View { + ErrorView( + title: errorTitle, + message: errorMessage, + onRetry: onRetry, + onDismiss: onDismiss + ) + } + + private var errorTitle: String { + switch error { + case .transferInitializationFailed: + return "Initialization Failed" + case .qrgenerationFailed: + return "QR Code Error" + case .transferInterrupted: + return "Transfer Interrupted" + default: + return "Error" + } + } + + private var errorMessage: String { + switch error { + case .transferInitializationFailed: + return "Unable to start the transfer. Please check your network and try again." + case .qrgenerationFailed: + return "Failed to generate QR code. Please try again." + case .transferInterrupted: + return "The transfer was interrupted. Please try again." + default: + return "An unknown error occurred." + } + } +} + +// MARK: - ViewModel Wrapper + +@MainActor +class SendViewModelWrapper: ObservableObject { + @Published private(set) var state: SendScreenState + @Published var effectPublisher = PassthroughSubject() + + private let viewModel: SendViewModel + private let reporter = KoinHelper.shared.getFirebaseReporter() + private var stateTask: Task? + private var effectTask: Task? + private var accessedURLs: [URL] = [] + + init() { + self.viewModel = DIContainer.shared.makeSendViewModel() + self.state = SendScreenState.FileSelection(files: [], size: 0, canStartTransfer: false) + reporter.log(message: "SendViewModelWrapper: initialized") + observeState() + observeEffects() + } + + func trackAccessedURL(_ url: URL) { + accessedURLs.append(url) + } + + private func observeState() { + stateTask = Task { [weak self] in + guard let self = self else { return } + + do { + for try await newState in viewModel.container.stateFlow { + let previousType = String(describing: type(of: self.state)) + let newType = String(describing: type(of: newState)) + if previousType != newType { + reporter.log(message: "SendViewModelWrapper: state changed - \(previousType) -> \(newType)") + } + self.state = newState as! SendScreenState + } + } catch { + reporter.recordError(message: "SendViewModelWrapper: state observation error - \(error.localizedDescription)", throwable: nil) + print("SendViewModel state error: \(error)") + } + } + } + + private func observeEffects() { + effectTask = Task { [weak self] in + guard let self = self else { return } + + do { + for try await effect in viewModel.container.sideEffectFlow { + if let typedEffect = effect as? SendScreenEffect { + reporter.log(message: "SendViewModelWrapper: effect - \(String(describing: typedEffect))") + self.effectPublisher.send(typedEffect) + } + } + } catch { + reporter.recordError(message: "SendViewModelWrapper: effect observation error - \(error.localizedDescription)", throwable: nil) + print("SendViewModel effect error: \(error)") + } + } + } + + func onFilesAdded(_ files: [String]) { + reporter.log(message: "SendViewModelWrapper: onFilesAdded - count: \(files.count)") + viewModel.onFilesAdded(newFiles: files) + } + + func onFileRemove(_ file: String) { + reporter.log(message: "SendViewModelWrapper: onFileRemove") + viewModel.onFileRemove(file: file) + } + + func onStartTransfer() { + reporter.log(message: "SendViewModelWrapper: onStartTransfer") + viewModel.onStartTransfer() + } + + func onCancelTransfer() { + reporter.log(message: "SendViewModelWrapper: onCancelTransfer") + viewModel.onCancelTransfer() + } + + func onCancelQrGeneration() { + reporter.log(message: "SendViewModelWrapper: onCancelQrGeneration") + viewModel.onCancelQrGeneration() + } + + func onSendMore() { + reporter.log(message: "SendViewModelWrapper: onSendMore") + viewModel.onSendMore() + } + + func onDone() { + reporter.log(message: "SendViewModelWrapper: onDone") + viewModel.onDone() + } + + func onErrorRetry() { + reporter.log(message: "SendViewModelWrapper: onErrorRetry") + viewModel.onErrorRetry() + } + + func onErrorDismiss() { + reporter.log(message: "SendViewModelWrapper: onErrorDismiss") + viewModel.onErrorDismiss() + } + + deinit { + // Release security-scoped resource access + accessedURLs.forEach { $0.stopAccessingSecurityScopedResource() } + accessedURLs.removeAll() + + stateTask?.cancel() + effectTask?.cancel() + reporter.log(message: "SendViewModelWrapper: deinitialized") + } +} + +#Preview { + NavigationStack { + SendView() + .environmentObject(NavigationCoordinator()) + } +} diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 11845e1..e6beb81 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -2,7 +2,19 @@ + CFBundleIconName + AppIcon CADisableMinimumFrameDurationOnPhone + NSCameraUsageDescription + Camera access is needed to scan QR codes for receiving files + NSPhotoLibraryUsageDescription + Photo library access is needed to select files for transfer + NSLocalNetworkUsageDescription + ARK Drop needs local network access to discover nearby devices for direct file transfer + LSSupportsOpeningDocumentsInPlace + + UISupportsDocumentBrowser + diff --git a/iosApp/iosApp/Theme/Colors.swift b/iosApp/iosApp/Theme/Colors.swift new file mode 100644 index 0000000..5f4b3a1 --- /dev/null +++ b/iosApp/iosApp/Theme/Colors.swift @@ -0,0 +1,55 @@ +import SwiftUI + +extension Color { + // MARK: - Primary Colors + static let dropPrimary = Color(hex: "6200EE") + static let dropPrimaryVariant = Color(hex: "3700B3") + static let dropSecondary = Color(hex: "03DAC6") + static let dropSecondaryVariant = Color(hex: "018786") + + // MARK: - Background Colors + static let dropBackground = Color(hex: "F5F5F5") + static let dropSurface = Color.white + static let dropCard = Color.white + + // MARK: - Status Colors + static let dropError = Color(hex: "B00020") + static let dropSuccess = Color(hex: "4CAF50") + static let dropWarning = Color(hex: "FF9800") + + // MARK: - Text Colors + static let dropOnPrimary = Color.white + static let dropOnSecondary = Color.black + static let dropOnBackground = Color(hex: "000000") + static let dropOnSurface = Color(hex: "000000") + static let dropOnError = Color.white + + // MARK: - Transfer Colors + static let dropSending = Color(hex: "2196F3") + static let dropReceiving = Color(hex: "4CAF50") + + // MARK: - Utility + init(hex: String) { + let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: hex).scanHexInt64(&int) + let a, r, g, b: UInt64 + switch hex.count { + case 3: // RGB (12-bit) + (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) + case 6: // RGB (24-bit) + (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) + case 8: // ARGB (32-bit) + (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) + default: + (a, r, g, b) = (255, 0, 0, 0) + } + self.init( + .sRGB, + red: Double(r) / 255, + green: Double(g) / 255, + blue: Double(b) / 255, + opacity: Double(a) / 255 + ) + } +} diff --git a/iosApp/iosApp/Theme/Spacing.swift b/iosApp/iosApp/Theme/Spacing.swift new file mode 100644 index 0000000..46dcc06 --- /dev/null +++ b/iosApp/iosApp/Theme/Spacing.swift @@ -0,0 +1,20 @@ +import SwiftUI + +struct Spacing { + static let xxxs: CGFloat = 2 + static let xxs: CGFloat = 4 + static let xs: CGFloat = 8 + static let sm: CGFloat = 12 + static let md: CGFloat = 16 + static let lg: CGFloat = 24 + static let xl: CGFloat = 32 + static let xxl: CGFloat = 48 + static let xxxl: CGFloat = 64 +} + +struct CornerRadius { + static let small: CGFloat = 8 + static let medium: CGFloat = 12 + static let large: CGFloat = 16 + static let extraLarge: CGFloat = 24 +} diff --git a/iosApp/iosApp/Theme/Typography.swift b/iosApp/iosApp/Theme/Typography.swift new file mode 100644 index 0000000..f49155e --- /dev/null +++ b/iosApp/iosApp/Theme/Typography.swift @@ -0,0 +1,28 @@ +import SwiftUI + +struct AppTypography { + // MARK: - Display + static let displayLarge = Font.system(size: 57, weight: .regular) + static let displayMedium = Font.system(size: 45, weight: .regular) + static let displaySmall = Font.system(size: 36, weight: .regular) + + // MARK: - Headline + static let headlineLarge = Font.system(size: 32, weight: .semibold) + static let headlineMedium = Font.system(size: 28, weight: .semibold) + static let headlineSmall = Font.system(size: 24, weight: .semibold) + + // MARK: - Title + static let titleLarge = Font.system(size: 22, weight: .medium) + static let titleMedium = Font.system(size: 16, weight: .medium) + static let titleSmall = Font.system(size: 14, weight: .medium) + + // MARK: - Body + static let bodyLarge = Font.system(size: 16, weight: .regular) + static let bodyMedium = Font.system(size: 14, weight: .regular) + static let bodySmall = Font.system(size: 12, weight: .regular) + + // MARK: - Label + static let labelLarge = Font.system(size: 14, weight: .medium) + static let labelMedium = Font.system(size: 12, weight: .medium) + static let labelSmall = Font.system(size: 11, weight: .medium) +} diff --git a/iosApp/iosApp/Utilities/Extensions.swift b/iosApp/iosApp/Utilities/Extensions.swift new file mode 100644 index 0000000..a69fbb3 --- /dev/null +++ b/iosApp/iosApp/Utilities/Extensions.swift @@ -0,0 +1,42 @@ +import Foundation +import Combine + +// MARK: - PassthroughSubject Extension + +extension PassthroughSubject where Output == Never { + static func create() -> PassthroughSubject { + return PassthroughSubject() + } +} + +// MARK: - String Extensions + +extension String { + var isNotEmpty: Bool { + !isEmpty + } +} + +// MARK: - URL Extensions + +extension URL { + var fileSize: UInt64 { + let attributes = try? FileManager.default.attributesOfItem(atPath: path) + return attributes?[.size] as? UInt64 ?? 0 + } +} + +// MARK: - View Extensions + +import SwiftUI + +extension View { + func hideKeyboard() { + UIApplication.shared.sendAction( + #selector(UIResponder.resignFirstResponder), + to: nil, + from: nil, + for: nil + ) + } +} diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index d83dca6..1b1f410 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -1,10 +1,30 @@ import SwiftUI +import Shared +import FirebaseCore @main struct iOSApp: App { + init() { + // Initialize Firebase, app configuration and DI + if Bundle.main.url(forResource: "GoogleService-Info", withExtension: "plist") != nil { + FirebaseApp.configure() + } + AppConfiguration.shared.initialize() + } + var body: some Scene { WindowGroup { - ContentView() + AppRootView() } } -} \ No newline at end of file +} + +struct AppRootView: View { + @StateObject private var coordinator = NavigationCoordinator() + + var body: some View { + HomeView() + .environmentObject(coordinator) + .preferredColorScheme(.light) // Force light mode for consistency + } +} diff --git a/shared/XCFramework-Info.plist b/shared/XCFramework-Info.plist new file mode 100644 index 0000000..2155cf7 --- /dev/null +++ b/shared/XCFramework-Info.plist @@ -0,0 +1,47 @@ + + + + + CFBundlePackageType + XFWK + XCFrameworkFormatVersion + 1.0 + AvailableLibraries + + + LibraryIdentifier + ios-arm64 + LibraryPath + Shared.framework + SupportedArchitectures + arm64 + SupportedPlatform + ios + + + LibraryIdentifier + ios-arm64_x86_64-simulator + LibraryPath + Shared.framework + SupportedArchitectures + arm64x86_64 + SupportedPlatform + ios + SupportedPlatformVariant + simulator + + + LibraryIdentifier + ios-x86_64-simulator + LibraryPath + Shared.framework + SupportedArchitectures + x86_64 + SupportedPlatform + ios + SupportedPlatformVariant + simulator + + + + diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index d5e80a1..5ed22f8 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -1,6 +1,7 @@ import org.gradle.kotlin.dsl.implementation import org.gradle.kotlin.dsl.type import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.plugin.mpp.apple.XCFramework plugins { alias(libs.plugins.kotlinMultiplatform) @@ -9,6 +10,7 @@ plugins { alias(libs.plugins.androidx.room) alias(libs.plugins.kotlinSerialization) alias(libs.plugins.ktlint.gradle) + alias(libs.plugins.skie) } kotlin { @@ -18,46 +20,144 @@ kotlin { } } + val xcf = XCFramework() + listOf( + iosX64(), iosArm64(), iosSimulatorArm64(), ).forEach { iosTarget -> iosTarget.binaries.framework { baseName = "Shared" isStatic = true + xcf.add(this) + } + + // Configure cinterops + iosTarget.compilations.getByName("main") { + val iosAppPath = rootProject.projectDir.resolve("iosApp/iosApp") + + cinterops.create("ArkDropBridge") { + defFile(project.file("src/nativeInterop/cinterop/ArkDropBridge.def")) + packageName("dev.arkbuilders.drop.bridge") + compilerOpts( + "-framework", + "Foundation", + "-I${iosAppPath.absolutePath}", + ) + includeDirs(iosAppPath.absolutePath) + } + + cinterops.create("CrashlyticsBridge") { + defFile(project.file("src/nativeInterop/cinterop/CrashlyticsBridge.def")) + packageName("dev.arkbuilders.drop.bridge") + compilerOpts( + "-framework", + "Foundation", + "-I${iosAppPath.absolutePath}", + ) + includeDirs(iosAppPath.absolutePath) + } + + cinterops.create("AnalyticsBridge") { + defFile(project.file("src/nativeInterop/cinterop/AnalyticsBridge.def")) + packageName("dev.arkbuilders.drop.bridge") + compilerOpts( + "-framework", + "Foundation", + "-I${iosAppPath.absolutePath}", + ) + includeDirs(iosAppPath.absolutePath) + } + + // Ensure cinterop runs before Kotlin compilation + compileTaskProvider.configure { + dependsOn( + "cinteropArkDropBridge${iosTarget.name.replaceFirstChar { it.uppercase() }}", + ) + dependsOn( + "cinteropCrashlyticsBridge" + + iosTarget.name.replaceFirstChar { it.uppercase() }, + ) + dependsOn( + "cinteropAnalyticsBridge" + + iosTarget.name.replaceFirstChar { it.uppercase() }, + ) + } + } + + // Link SystemConfiguration framework for network monitoring + iosTarget.binaries.all { + linkerOpts += listOf("-framework", "SystemConfiguration") } } sourceSets { - commonMain.dependencies { - // put your Multiplatform dependencies here - implementation(libs.orbit.core) - implementation(libs.orbit.viewmodel) - implementation(libs.androidx.datastore) - implementation(libs.androidx.datastore.preferences) - implementation(libs.kermit) - implementation(libs.coroutines.core) - implementation(libs.koin.core) - implementation(libs.koin.core.viewmodel) - implementation(libs.androidx.room.runtime) - implementation(libs.androidx.sqlite.bundled) - implementation(libs.kotlinx.serialization.json) + // Common + val commonMain by getting { + dependencies { + implementation(libs.orbit.core) + implementation(libs.orbit.viewmodel) + implementation(libs.androidx.datastore) + implementation(libs.androidx.datastore.preferences) + implementation(libs.kermit) + implementation(libs.coroutines.core) + implementation(libs.koin.core) + implementation(libs.koin.core.viewmodel) + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.sqlite.bundled) + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.datetime) + } } - commonTest.dependencies { - implementation(libs.kotlin.test) + val commonTest by getting { + dependencies { + implementation(libs.kotlin.test) + } } - androidMain.dependencies { - implementation(libs.google.zxing.core) - implementation(libs.coroutines.android) - implementation(libs.koin.android) - implementation(libs.jna.get().toString()) { - artifact { - type = "aar" - extension = "aar" + + // Android + val androidMain by getting { + dependencies { + implementation(libs.google.zxing.core) + implementation(libs.coroutines.android) + implementation(libs.koin.android) + implementation(libs.jna.get().toString()) { + artifact { + type = "aar" + extension = "aar" + } } + implementation(libs.arkbuilders.drop) + implementation(libs.timber) + implementation(project.dependencies.platform(libs.firebase.bom)) + implementation(libs.firebase.crashlytics) + implementation(libs.firebase.analytics) } - implementation(libs.arkbuilders.drop) - implementation(libs.timber) + } + + // iOS platform source sets + val iosX64Main by getting + val iosArm64Main by getting + val iosSimulatorArm64Main by getting + + // Create shared iosMain + val iosMain by creating { + dependsOn(commonMain) + iosX64Main.dependsOn(this) + iosArm64Main.dependsOn(this) + iosSimulatorArm64Main.dependsOn(this) + } + + val iosX64Test by getting + val iosArm64Test by getting + val iosSimulatorArm64Test by getting + + val iosTest by creating { + dependsOn(commonTest) + iosX64Test.dependsOn(this) + iosArm64Test.dependsOn(this) + iosSimulatorArm64Test.dependsOn(this) } } } @@ -78,10 +178,20 @@ dependencies { add("kspAndroid", libs.androidx.room.compiler) add("kspIosSimulatorArm64", libs.androidx.room.compiler) add("kspIosArm64", libs.androidx.room.compiler) - // add("kspIosX64", libs.androidx.room.compiler) - // Add any other platform target you use in your project, for example kspDesktop + add("kspIosX64", libs.androidx.room.compiler) } room { schemaDirectory("$projectDir/schemas") } + +// Copy XCFramework to path Xcode project expects (build/XCFrameworks/debug) +// CI builds Release but Xcode project references XCFrameworks/debug +// Uses Copy task for configuration-cache compatibility (avoids project ref at execution) +tasks.register("copyFrameworkForXcode") { + val assembleTask = tasks.findByName("assembleSharedReleaseXCFramework") + if (assembleTask != null) dependsOn(assembleTask) + from(layout.buildDirectory.dir("XCFrameworks/release")) + into(layout.buildDirectory.dir("XCFrameworks/debug")) + include("shared.xcframework", "Shared.xcframework") +} diff --git a/shared/src/androidMain/kotlin/dev/arkbuilders/drop/di/PlatformModule.android.kt b/shared/src/androidMain/kotlin/dev/arkbuilders/drop/di/PlatformModule.android.kt index fdbeac6..38a29df 100644 --- a/shared/src/androidMain/kotlin/dev/arkbuilders/drop/di/PlatformModule.android.kt +++ b/shared/src/androidMain/kotlin/dev/arkbuilders/drop/di/PlatformModule.android.kt @@ -11,6 +11,8 @@ import dev.arkbuilders.drop.data.helper.PermissionsHelper import dev.arkbuilders.drop.data.helper.ResourcesHelper import dev.arkbuilders.drop.data.settings.DATASTORE_FILENAME import dev.arkbuilders.drop.data.settings.createDataStore +import dev.arkbuilders.drop.instrumentation.AnalyticsReporter +import dev.arkbuilders.drop.instrumentation.FirebaseReporter import kotlinx.coroutines.Dispatchers import org.koin.android.ext.koin.androidApplication import org.koin.android.ext.koin.androidContext @@ -23,6 +25,11 @@ actual val platformModule: Module = single { NetworkStatus(androidContext()) } single { PermissionsHelper(androidContext()) } single { ResourcesHelper(androidContext()) } + single { FirebaseReporter() } + single { + AnalyticsReporter.initialize(androidContext()) + AnalyticsReporter() + } single { val dbFile = androidApplication().getDatabasePath(DropDatabase.DB_NAME) diff --git a/shared/src/androidMain/kotlin/dev/arkbuilders/drop/instrumentation/AnalyticsReporter.android.kt b/shared/src/androidMain/kotlin/dev/arkbuilders/drop/instrumentation/AnalyticsReporter.android.kt new file mode 100644 index 0000000..4a414f9 --- /dev/null +++ b/shared/src/androidMain/kotlin/dev/arkbuilders/drop/instrumentation/AnalyticsReporter.android.kt @@ -0,0 +1,40 @@ +package dev.arkbuilders.drop.instrumentation + +import android.content.Context +import android.os.Bundle +import com.google.firebase.analytics.FirebaseAnalytics + +actual class AnalyticsReporter actual constructor() { + private val instance: FirebaseAnalytics by lazy { + FirebaseAnalytics.getInstance(requireNotNull(applicationContext)) + } + + actual fun logEvent( + name: String, + params: Map, + ) { + val bundle = + Bundle().apply { + params.forEach { (key, value) -> + when (value) { + is Byte -> putLong(key, value.toLong()) + is Short -> putLong(key, value.toLong()) + is Int -> putLong(key, value.toLong()) + is Long -> putLong(key, value) + is Float -> putDouble(key, value.toDouble()) + is Double -> putDouble(key, value) + else -> putString(key, value.toString()) + } + } + } + instance.logEvent(name, bundle) + } + + companion object { + private var applicationContext: Context? = null + + fun initialize(context: Context) { + applicationContext = context.applicationContext + } + } +} diff --git a/shared/src/androidMain/kotlin/dev/arkbuilders/drop/instrumentation/FirebaseReporter.android.kt b/shared/src/androidMain/kotlin/dev/arkbuilders/drop/instrumentation/FirebaseReporter.android.kt new file mode 100644 index 0000000..5f264d6 --- /dev/null +++ b/shared/src/androidMain/kotlin/dev/arkbuilders/drop/instrumentation/FirebaseReporter.android.kt @@ -0,0 +1,29 @@ +package dev.arkbuilders.drop.instrumentation + +import com.google.firebase.crashlytics.FirebaseCrashlytics + +actual class FirebaseReporter { + private val instance = FirebaseCrashlytics.getInstance() + + actual fun recordError( + message: String, + throwable: Throwable?, + ) { + if (throwable != null) { + instance.recordException(throwable) + } else { + instance.recordException(Exception(message)) + } + } + + actual fun log(message: String) { + instance.log(message) + } + + actual fun setCustomKey( + key: String, + value: String, + ) { + instance.setCustomKey(key, value) + } +} diff --git a/shared/src/commonMain/kotlin/dev/arkbuilders/drop/data/repository/ReceiveSessionRepoImpl.kt b/shared/src/commonMain/kotlin/dev/arkbuilders/drop/data/repository/ReceiveSessionRepoImpl.kt index d87384b..134b85a 100644 --- a/shared/src/commonMain/kotlin/dev/arkbuilders/drop/data/repository/ReceiveSessionRepoImpl.kt +++ b/shared/src/commonMain/kotlin/dev/arkbuilders/drop/data/repository/ReceiveSessionRepoImpl.kt @@ -9,6 +9,7 @@ import dev.arkbuilders.drop.domain.model.TransferStatus import dev.arkbuilders.drop.domain.repository.ReceiveSessionRepo import dev.arkbuilders.drop.domain.repository.TransferSessionRepo import dev.arkbuilders.drop.domain.usecase.ReceiveFilesUseCase +import dev.arkbuilders.drop.instrumentation.FirebaseReporter import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -22,6 +23,7 @@ class ReceiveSessionRepoImpl( private val receiveFilesUseCase: ReceiveFilesUseCase, private val transferHistoryRepository: TransferSessionRepo, private val resourcesHelper: ResourcesHelper, + private val firebaseReporter: FirebaseReporter, ) : ReceiveSessionRepo { // Keep references to active sessions here so file transfers continue even if the ViewModel dies private val activeSessions = mutableListOf() @@ -33,8 +35,14 @@ class ReceiveSessionRepoImpl( confirmation: UByte, ): ReceiveSession? = withContext(Dispatchers.IO) { + firebaseReporter.log("ReceiveSessionRepo: receiveFiles") + receiveFilesUseCase.invoke(ticket, confirmation).fold( onSuccess = { bubble -> + firebaseReporter.log( + "ReceiveSessionRepo: use case returned bubble, creating subscriber", + ) + val subscriber = getDropApi().createReceiveSubscriber().also { subscriber -> bubble.subscribe(subscriber) @@ -47,12 +55,24 @@ class ReceiveSessionRepoImpl( ) activeSessionsMutex.withLock { activeSessions.add(session) + firebaseReporter.setCustomKey( + "active_receive_count", + activeSessions.size.toString(), + ) } + firebaseReporter.log("ReceiveSessionRepo: starting bubble") bubble.start() + firebaseReporter.log( + "ReceiveSessionRepo: session created and started successfully", + ) return@withContext session }, - onFailure = { + onFailure = { e -> + firebaseReporter.recordError( + "ReceiveSessionRepo: receiveFiles failed", + e, + ) return@withContext null }, ) @@ -64,21 +84,36 @@ class ReceiveSessionRepoImpl( val completeFiles = subscriber.getCompleteFiles() val savedFiles = mutableListOf() + firebaseReporter.log( + "ReceiveSessionRepo: saveReceivedFiles completeFiles=${completeFiles.size}", + ) + try { completeFiles.forEach { (fileInfo, data) -> + firebaseReporter.log( + "ReceiveSessionRepo: " + + "saving file size=${fileInfo.size}", + ) val savedFile = resourcesHelper.saveFileToDownloads(fileInfo.name, data) if (savedFile != null) { savedFiles.add(DropFileInfo(savedFile, fileInfo.size.toLong())) - Logger.i("Saved file name: $savedFile") + Logger.i("Saved received file") + firebaseReporter.log("ReceiveSessionRepo: file saved") } else { - Logger.e("Failed to save file: ${fileInfo.name}") + Logger.e("Failed to save received file") + firebaseReporter.recordError("ReceiveSessionRepo: failed to save file") } } + val progress = subscriber.progress.value + val senderName = progress.senderName + val senderAvatar = progress.senderAvatar + if (savedFiles.isNotEmpty()) { - val progress = subscriber.progress.value - val senderName = progress.senderName - val senderAvatar = progress.senderAvatar + firebaseReporter.log( + "ReceiveSessionRepo: adding completed transfer to history " + + "files=${savedFiles.size}", + ) transferHistoryRepository.addReceivedTransfer( files = savedFiles, @@ -86,9 +121,24 @@ class ReceiveSessionRepoImpl( peerAvatar = senderAvatar, status = TransferStatus.COMPLETED, ) + firebaseReporter.log( + "ReceiveSessionRepo: transfer history entry added successfully", + ) + } else { + firebaseReporter.log( + "ReceiveSessionRepo: no files saved, adding failed transfer to history", + ) + + transferHistoryRepository.addReceivedTransfer( + files = emptyList(), + peerName = senderName, + peerAvatar = senderAvatar, + status = TransferStatus.FAILED, + ) } } catch (e: Exception) { Logger.e("Error saving received files ${e.message}") + firebaseReporter.recordError("ReceiveSessionRepo: saveReceivedFiles exception", e) val progress = subscriber.progress.value val senderName = progress.senderName @@ -102,19 +152,29 @@ class ReceiveSessionRepoImpl( ) } + firebaseReporter.log( + "ReceiveSessionRepo: saveReceivedFiles completed savedCount=${savedFiles.size}", + ) return@withContext savedFiles.map { it.name } } override fun cancelReceive(session: ReceiveSession) { + firebaseReporter.log("ReceiveSessionRepo: cancelReceive session=${session.hashCode()}") cancelScope.launch { try { activeSessionsMutex.withLock { activeSessions.remove(session) + firebaseReporter.setCustomKey( + "active_receive_count", + activeSessions.size.toString(), + ) } session.bubble.unsubscribe(session.subscriber) session.bubble.cancel() + firebaseReporter.log("ReceiveSessionRepo: session cancelled successfully") } catch (e: Throwable) { Logger.e("Error cancelling receive ${e.message}") + firebaseReporter.recordError("ReceiveSessionRepo: cancelReceive error", e) } } } diff --git a/shared/src/commonMain/kotlin/dev/arkbuilders/drop/data/repository/SendSessionRepoImpl.kt b/shared/src/commonMain/kotlin/dev/arkbuilders/drop/data/repository/SendSessionRepoImpl.kt index e67567c..d0e8573 100644 --- a/shared/src/commonMain/kotlin/dev/arkbuilders/drop/data/repository/SendSessionRepoImpl.kt +++ b/shared/src/commonMain/kotlin/dev/arkbuilders/drop/data/repository/SendSessionRepoImpl.kt @@ -9,6 +9,7 @@ import dev.arkbuilders.drop.domain.model.TransferStatus import dev.arkbuilders.drop.domain.repository.SendSessionRepo import dev.arkbuilders.drop.domain.repository.TransferSessionRepo import dev.arkbuilders.drop.domain.usecase.SendFilesUseCase +import dev.arkbuilders.drop.instrumentation.FirebaseReporter import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -22,6 +23,7 @@ class SendSessionRepoImpl( private val sendUseCase: SendFilesUseCase, private val resourcesHelper: ResourcesHelper, private val transferSessionRepository: TransferSessionRepo, + private val firebaseReporter: FirebaseReporter, ) : SendSessionRepo { // Keep references to active sessions here so file transfers continue even if the ViewModel dies private val activeSessions = mutableListOf() @@ -30,7 +32,11 @@ class SendSessionRepoImpl( override suspend fun sendFiles(fileUris: List): SendSession? = withContext(Dispatchers.IO) { - cleanupFinishedSessions() + val cleaned = cleanupFinishedSessions() + firebaseReporter.log( + "SendSessionRepo: sendFiles called - " + + "files: ${fileUris.size}, cleaned sessions: $cleaned", + ) sendUseCase.invoke(fileUris).fold( onSuccess = { bubble -> @@ -40,12 +46,21 @@ class SendSessionRepoImpl( } val session = SendSession(bubble, subscriber) + firebaseReporter.log( + "SendSessionRepo: session created - " + + "activeSessions: ${activeSessions.size + 1}", + ) + activeSessionsMutex.withLock { activeSessions.add(session) } return@withContext session }, - onFailure = { + onFailure = { error -> + firebaseReporter.recordError( + "SendSessionRepo: sendFiles failed - ${error.message}", + error, + ) return@withContext null }, ) @@ -56,16 +71,28 @@ class SendSessionRepoImpl( session: SendSession, ) { try { + firebaseReporter.log( + "SendSessionRepo: recording send completion for ${fileUris.size} files", + ) cleanupFinishedSessions() val progress = session.subscriber.progress.value val receiverName = progress.receiverName val receiverAvatar = progress.receiverAvatar + val totalSent = progress.sent + val totalRemaining = progress.remaining + + firebaseReporter.log( + "SendSessionRepo: transfer completed - " + + "sent: $totalSent, remaining: $totalRemaining", + ) val filesInfo = fileUris.map { + val name = resourcesHelper.getFileName(it) ?: "" + val size = resourcesHelper.getFileSize(it) DropFileInfo( - name = resourcesHelper.getFileName(it) ?: "", - size = resourcesHelper.getFileSize(it), + name = name, + size = size, ) } @@ -75,12 +102,20 @@ class SendSessionRepoImpl( peerAvatar = receiverAvatar, status = TransferStatus.COMPLETED, ) + + firebaseReporter.log("SendSessionRepo: send completion recorded successfully") } catch (e: Exception) { + firebaseReporter.recordError( + "SendSessionRepo: error recording send completion - ${e.message}", + e, + ) Logger.e("Error recording send completion ${e.message}") } } override fun cancelSend(session: SendSession) { + firebaseReporter.log("SendSessionRepo: cancelling send") + cancelScope.launch { try { activeSessionsMutex.withLock { @@ -88,7 +123,12 @@ class SendSessionRepoImpl( } session.bubble.unsubscribe(session.subscriber) session.bubble.cancel() + firebaseReporter.log("SendSessionRepo: send cancelled successfully") } catch (e: Throwable) { + firebaseReporter.recordError( + "SendSessionRepo: error during cancel - ${e::class.simpleName}", + e, + ) Logger.e("Error cancelling send ${e.message}") } } @@ -96,6 +136,15 @@ class SendSessionRepoImpl( private suspend fun cleanupFinishedSessions() = activeSessionsMutex.withLock { + val before = activeSessions.size activeSessions.removeAll { it.bubble.isFinished() } + val removed = before - activeSessions.size + if (removed > 0) { + firebaseReporter.log( + "SendSessionRepo: cleaned $removed finished sessions, " + + "${activeSessions.size} remaining", + ) + } + removed } } diff --git a/shared/src/commonMain/kotlin/dev/arkbuilders/drop/di/RepositoriesModule.kt b/shared/src/commonMain/kotlin/dev/arkbuilders/drop/di/RepositoriesModule.kt index 9d1900d..4c981b8 100644 --- a/shared/src/commonMain/kotlin/dev/arkbuilders/drop/di/RepositoriesModule.kt +++ b/shared/src/commonMain/kotlin/dev/arkbuilders/drop/di/RepositoriesModule.kt @@ -10,6 +10,7 @@ import dev.arkbuilders.drop.domain.repository.ProfileRepo import dev.arkbuilders.drop.domain.repository.ReceiveSessionRepo import dev.arkbuilders.drop.domain.repository.SendSessionRepo import dev.arkbuilders.drop.domain.repository.TransferSessionRepo +import dev.arkbuilders.drop.instrumentation.FirebaseReporter import org.koin.dsl.module val repositoriesModule = @@ -18,7 +19,21 @@ val repositoriesModule = single { TransferSessionLocalDataSource(get()) } single { ProfileRepoImpl(get()) } - single { SendSessionRepoImpl(get(), get(), get()) } - single { ReceiveSessionRepoImpl(get(), get(), get()) } + single { + SendSessionRepoImpl( + get(), + get(), + get(), + get(), + ) + } + single { + ReceiveSessionRepoImpl( + get(), + get(), + get(), + get(), + ) + } single { TransferSessionRepoImpl(get()) } } diff --git a/shared/src/commonMain/kotlin/dev/arkbuilders/drop/di/UseCaseModule.kt b/shared/src/commonMain/kotlin/dev/arkbuilders/drop/di/UseCaseModule.kt index 650140c..3429506 100644 --- a/shared/src/commonMain/kotlin/dev/arkbuilders/drop/di/UseCaseModule.kt +++ b/shared/src/commonMain/kotlin/dev/arkbuilders/drop/di/UseCaseModule.kt @@ -2,10 +2,11 @@ package dev.arkbuilders.drop.di import dev.arkbuilders.drop.domain.usecase.ReceiveFilesUseCase import dev.arkbuilders.drop.domain.usecase.SendFilesUseCase +import dev.arkbuilders.drop.instrumentation.FirebaseReporter import org.koin.dsl.module val useCaseModule = module { - factory { SendFilesUseCase(get(), get()) } - factory { ReceiveFilesUseCase(get()) } + factory { SendFilesUseCase(get(), get(), get()) } + factory { ReceiveFilesUseCase(get(), get()) } } diff --git a/shared/src/commonMain/kotlin/dev/arkbuilders/drop/di/ViewModelModule.kt b/shared/src/commonMain/kotlin/dev/arkbuilders/drop/di/ViewModelModule.kt index 20b63b5..7c4cfd3 100644 --- a/shared/src/commonMain/kotlin/dev/arkbuilders/drop/di/ViewModelModule.kt +++ b/shared/src/commonMain/kotlin/dev/arkbuilders/drop/di/ViewModelModule.kt @@ -1,5 +1,7 @@ package dev.arkbuilders.drop.di +import dev.arkbuilders.drop.instrumentation.AnalyticsReporter +import dev.arkbuilders.drop.instrumentation.FirebaseReporter import dev.arkbuilders.drop.presentation.edit.EditProfileViewModel import dev.arkbuilders.drop.presentation.history.HistoryViewModel import dev.arkbuilders.drop.presentation.home.HomeViewModel @@ -10,9 +12,24 @@ import org.koin.dsl.module val viewModelModule = module { - viewModel { HistoryViewModel(get()) } + viewModel { HistoryViewModel(get(), get()) } viewModel { HomeViewModel(get(), get(), get()) } - viewModel { EditProfileViewModel(get(), get()) } - viewModel { ReceiveViewModel(get(), get()) } - viewModel { SendViewModel(get(), get(), get()) } + viewModel { EditProfileViewModel(get(), get(), get()) } + viewModel { + ReceiveViewModel( + get(), + get(), + get(), + get(), + ) + } + viewModel { + SendViewModel( + get(), + get(), + get(), + get(), + get(), + ) + } } diff --git a/shared/src/commonMain/kotlin/dev/arkbuilders/drop/domain/usecase/ReceiveFilesUseCase.kt b/shared/src/commonMain/kotlin/dev/arkbuilders/drop/domain/usecase/ReceiveFilesUseCase.kt index 6c1ff3c..6938ac5 100644 --- a/shared/src/commonMain/kotlin/dev/arkbuilders/drop/domain/usecase/ReceiveFilesUseCase.kt +++ b/shared/src/commonMain/kotlin/dev/arkbuilders/drop/domain/usecase/ReceiveFilesUseCase.kt @@ -7,6 +7,7 @@ import dev.arkbuilders.drop.domain.libwrapper.receive.request.DropReceiveFilesRe import dev.arkbuilders.drop.domain.libwrapper.receive.request.DropReceiverConfig import dev.arkbuilders.drop.domain.libwrapper.receive.request.DropReceiverProfile import dev.arkbuilders.drop.domain.repository.ProfileRepo +import dev.arkbuilders.drop.instrumentation.FirebaseReporter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.flow.first @@ -14,6 +15,7 @@ import kotlinx.coroutines.withContext class ReceiveFilesUseCase( private val profileRepo: ProfileRepo, + private val firebaseReporter: FirebaseReporter, ) { suspend operator fun invoke( ticket: String, @@ -21,15 +23,25 @@ class ReceiveFilesUseCase( ): Result = withContext(Dispatchers.IO) { runCatching { - Logger.d("Starting file receive with ticket: $ticket") + firebaseReporter.log("ReceiveFilesUseCase: invoked") + Logger.d("Starting file receive") val profile = profileRepo.profile.first() + firebaseReporter.log( + "ReceiveFilesUseCase: profile loaded " + + "hasAvatar=${profile.avatar.base64.isNotEmpty()}", + ) + val receiverProfile = DropReceiverProfile( name = profile.name.ifEmpty { "Anonymous" }, avatarB64 = profile.avatar.base64.takeIf { it.isNotEmpty() }, ) + // Using UInt values, converted to ULong for the config + val chunkSize = 1024u * 512u // UInt + val parallelStreams = 4u // UInt + val request = DropReceiveFilesRequest( ticket = ticket, @@ -37,17 +49,24 @@ class ReceiveFilesUseCase( profile = receiverProfile, config = DropReceiverConfig( - chunkSize = 1024u * 512u, - parallelStreams = 4u, + chunkSize = chunkSize.toULong(), + parallelStreams = parallelStreams.toULong(), ), ) + firebaseReporter.log( + "ReceiveFilesUseCase: request created " + + "chunkSize=$chunkSize parallelStreams=$parallelStreams", + ) + val bubble = getDropApi().receiveFiles(request) + firebaseReporter.log("ReceiveFilesUseCase: bubble created successfully") Logger.d("Receive bubble created and started") bubble - }.onFailure { - Logger.e("Error starting file receive ${it.message}") + }.onFailure { e -> + Logger.e("Error starting file receive ${e.message}") + firebaseReporter.recordError("ReceiveFilesUseCase: failed", e) } } } diff --git a/shared/src/commonMain/kotlin/dev/arkbuilders/drop/domain/usecase/SendFilesUseCase.kt b/shared/src/commonMain/kotlin/dev/arkbuilders/drop/domain/usecase/SendFilesUseCase.kt index c6daae8..a1dec64 100644 --- a/shared/src/commonMain/kotlin/dev/arkbuilders/drop/domain/usecase/SendFilesUseCase.kt +++ b/shared/src/commonMain/kotlin/dev/arkbuilders/drop/domain/usecase/SendFilesUseCase.kt @@ -9,6 +9,7 @@ import dev.arkbuilders.drop.domain.libwrapper.send.request.DropSenderConfig import dev.arkbuilders.drop.domain.libwrapper.send.request.DropSenderFile import dev.arkbuilders.drop.domain.libwrapper.send.request.DropSenderProfile import dev.arkbuilders.drop.domain.repository.ProfileRepo +import dev.arkbuilders.drop.instrumentation.FirebaseReporter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.flow.first @@ -17,11 +18,16 @@ import kotlinx.coroutines.withContext class SendFilesUseCase( private val profileRepo: ProfileRepo, private val resourcesHelper: ResourcesHelper, + private val firebaseReporter: FirebaseReporter, ) { suspend operator fun invoke(fileUris: List): Result = withContext(Dispatchers.IO) { runCatching { Logger.d("Starting file send for ${fileUris.size} files") + firebaseReporter.setCustomKey("send_file_count", fileUris.size.toString()) + firebaseReporter.log( + "SendFilesUseCase: starting file send fileCount=${fileUris.size}", + ) val profile = profileRepo.profile.first() val senderProfile = @@ -29,48 +35,85 @@ class SendFilesUseCase( name = profile.name.ifEmpty { "Anonymous" }, avatarB64 = profile.avatar.base64.takeIf { it.isNotEmpty() }, ) + firebaseReporter.log( + "SendFilesUseCase: sender profile loaded - " + + "hasAvatar: ${senderProfile.avatarB64 != null}", + ) + var skippedCount = 0 + var totalBytes = 0L val senderFiles = fileUris.mapNotNull { uri -> val fileName = resourcesHelper.getFileName(uri) if (fileName != null) { val fileData = resourcesHelper.mapToSenderFileData(uri) + val fileSize = resourcesHelper.getFileSize(uri) + totalBytes += fileSize.coerceAtLeast(0L) DropSenderFile( name = fileName, data = fileData, ) } else { - Logger.w("Could not get filename for URI: $uri") + Logger.w("Could not get filename for selected file") + firebaseReporter.log( + "SendFilesUseCase: file skipped because filename was unavailable", + ) + skippedCount++ null } } if (senderFiles.isEmpty()) { + firebaseReporter.recordError( + "SendFilesUseCase: no valid files to send after processing " + + "${fileUris.size} URIs, skipped: $skippedCount", + ) Logger.e("No valid files to send") error("No valid files to send") } + if (skippedCount > 0) { + firebaseReporter.log( + "SendFilesUseCase: $skippedCount files skipped, " + + "${senderFiles.size} files will be sent", + ) + } + firebaseReporter.setCustomKey("send_total_bytes", totalBytes.toString()) + + // Using UInt values, converted to ULong for the config + val chunkSize = 1024u * 512u // UInt + val parallelStreams = 4u // UInt + val request = DropSendFilesRequest( profile = senderProfile, files = senderFiles, config = DropSenderConfig( - chunkSize = 1024u * 512u, - parallelStreams = 4u, + chunkSize = chunkSize.toULong(), + parallelStreams = parallelStreams.toULong(), ), ) + firebaseReporter.log( + "SendFilesUseCase: request built - " + + "files: ${senderFiles.size}, " + + "totalBytes: $totalBytes, " + + "chunkSize: ${request.config?.chunkSize}, " + + "parallelStreams: ${request.config?.parallelStreams}", + ) val bubble: DropSendFilesBubble = getDropApi().sendFiles(request) - Logger.d( - "Send bubble created with ticket and confirmation: ${ - bubble.getTicket() - } ${bubble.getConfirmation()}", + firebaseReporter.log( + "SendFilesUseCase: bubble created - " + + "createdAt: ${bubble.getCreatedAt()}", ) + + Logger.d("Send bubble created") bubble - }.onFailure { - Logger.e("Error starting file send ${it.message}", it) + }.onFailure { error -> + firebaseReporter.recordError("SendFilesUseCase: failed - ${error.message}", error) + Logger.e("Error starting file send ${error.message}", error) } } } diff --git a/shared/src/commonMain/kotlin/dev/arkbuilders/drop/instrumentation/AnalyticsEvents.kt b/shared/src/commonMain/kotlin/dev/arkbuilders/drop/instrumentation/AnalyticsEvents.kt new file mode 100644 index 0000000..6d0b0d5 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/arkbuilders/drop/instrumentation/AnalyticsEvents.kt @@ -0,0 +1,71 @@ +package dev.arkbuilders.drop.instrumentation + +object AnalyticsEvents { + const val APP_START = "app_start" + const val SCREEN_VIEW = "screen_view" + + const val SEND_FILES_SELECTED = "send_files_selected" + const val SEND_STARTED = "send_started" + const val SEND_QR_READY = "send_qr_ready" + const val SEND_RECEIVER_CONNECTED = "send_receiver_connected" + const val SEND_COMPLETED = "send_completed" + const val SEND_CANCELLED = "send_cancelled" + const val SEND_FAILED = "send_failed" + + const val RECEIVE_SCAN_STARTED = "receive_scan_started" + const val RECEIVE_CODE_ENTERED = "receive_code_entered" + const val RECEIVE_STARTED = "receive_started" + const val RECEIVE_SENDER_CONNECTED = "receive_sender_connected" + const val RECEIVE_COMPLETED = "receive_completed" + const val RECEIVE_CANCELLED = "receive_cancelled" + const val RECEIVE_FAILED = "receive_failed" + const val CAMERA_PERMISSION_RESULT = "camera_permission_result" + const val PROFILE_SAVED = "profile_saved" + const val HISTORY_CLEARED = "history_cleared" + const val HISTORY_ITEM_DELETED = "history_item_deleted" + const val SEND_CODE_COPIED = "send_code_copied" + const val RECEIVE_DEEP_LINK_OPENED = "receive_deep_link_opened" + const val SEND_FILE_PICKER_OPENED = "send_file_picker_opened" + const val SEND_FILE_REMOVED = "send_file_removed" + const val SEND_MORE_SELECTED = "send_more_selected" + const val RECEIVE_MANUAL_INPUT_STARTED = "receive_manual_input_started" + const val RECEIVE_CLIPBOARD_PASTED = "receive_clipboard_pasted" + const val RECEIVE_MORE_SELECTED = "receive_more_selected" + const val PROFILE_IMAGE_PICKER_OPENED = "profile_image_picker_opened" + + const val PARAM_FILE_COUNT = "file_count" + const val PARAM_SKIPPED_COUNT = "skipped_count" + const val PARAM_TOTAL_BYTES = "total_bytes" + const val PARAM_TOTAL_SIZE_BUCKET = "total_size_bucket" + const val PARAM_DURATION_MS = "duration_ms" + const val PARAM_PHASE = "phase" + const val PARAM_ERROR_TYPE = "error_type" + const val PARAM_SOURCE = "source" + const val PARAM_GRANTED = "granted" + const val PARAM_PLATFORM = "platform" + const val PARAM_SCREEN_NAME = "screen_name" + const val PARAM_SCREEN_CLASS = "screen_class" + const val PARAM_CHANGED_NAME = "changed_name" + const val PARAM_CHANGED_AVATAR = "changed_avatar" + const val PARAM_ITEM_COUNT = "item_count" + const val PARAM_HAS_TEXT = "has_text" + const val PARAM_REMAINING_FILE_COUNT = "remaining_file_count" + + const val SOURCE_QR = "qr" + const val SOURCE_MANUAL = "manual" + const val SOURCE_UNKNOWN = "unknown" + + fun sizeBucket(bytes: Long): String = + when { + bytes < 1L * 1024L * 1024L -> "0_1mb" + bytes < 10L * 1024L * 1024L -> "1_10mb" + bytes < 100L * 1024L * 1024L -> "10_100mb" + bytes < 1L * 1024L * 1024L * 1024L -> "100mb_1gb" + else -> "1gb_plus" + } + + fun durationSince( + startedAtMs: Long?, + nowMs: Long, + ): Long = startedAtMs?.let { (nowMs - it).coerceAtLeast(0L) } ?: -1L +} diff --git a/shared/src/commonMain/kotlin/dev/arkbuilders/drop/instrumentation/AnalyticsReporter.kt b/shared/src/commonMain/kotlin/dev/arkbuilders/drop/instrumentation/AnalyticsReporter.kt new file mode 100644 index 0000000..5e03e05 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/arkbuilders/drop/instrumentation/AnalyticsReporter.kt @@ -0,0 +1,8 @@ +package dev.arkbuilders.drop.instrumentation + +expect class AnalyticsReporter() { + fun logEvent( + name: String, + params: Map = emptyMap(), + ) +} diff --git a/shared/src/commonMain/kotlin/dev/arkbuilders/drop/instrumentation/FirebaseReporter.kt b/shared/src/commonMain/kotlin/dev/arkbuilders/drop/instrumentation/FirebaseReporter.kt new file mode 100644 index 0000000..2707286 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/arkbuilders/drop/instrumentation/FirebaseReporter.kt @@ -0,0 +1,15 @@ +package dev.arkbuilders.drop.instrumentation + +expect class FirebaseReporter { + fun recordError( + message: String, + throwable: Throwable? = null, + ) + + fun log(message: String) + + fun setCustomKey( + key: String, + value: String, + ) +} diff --git a/shared/src/commonMain/kotlin/dev/arkbuilders/drop/presentation/edit/EditProfileViewModel.kt b/shared/src/commonMain/kotlin/dev/arkbuilders/drop/presentation/edit/EditProfileViewModel.kt index fd7885a..da5fd48 100644 --- a/shared/src/commonMain/kotlin/dev/arkbuilders/drop/presentation/edit/EditProfileViewModel.kt +++ b/shared/src/commonMain/kotlin/dev/arkbuilders/drop/presentation/edit/EditProfileViewModel.kt @@ -5,6 +5,8 @@ import dev.arkbuilders.drop.data.helper.AvatarHelper import dev.arkbuilders.drop.domain.model.UserAvatar import dev.arkbuilders.drop.domain.model.UserProfile import dev.arkbuilders.drop.domain.repository.ProfileRepo +import dev.arkbuilders.drop.instrumentation.AnalyticsEvents +import dev.arkbuilders.drop.instrumentation.AnalyticsReporter import kotlinx.coroutines.flow.first import org.orbitmvi.orbit.Container import org.orbitmvi.orbit.ContainerHost @@ -35,6 +37,7 @@ sealed class EditProfileScreenEffect { class EditProfileViewModel( private val profileRepo: ProfileRepo, private val avatarHelper: AvatarHelper, + private val analyticsReporter: AnalyticsReporter, ) : ViewModel(), ContainerHost { override val container: Container = container( @@ -75,6 +78,7 @@ class EditProfileViewModel( fun onPickImage() = intent { + analyticsReporter.logEvent(AnalyticsEvents.PROFILE_IMAGE_PICKER_OPENED) postSideEffect(EditProfileScreenEffect.LaunchImagePicker) } @@ -108,6 +112,15 @@ class EditProfileViewModel( fun onSave() = intent { + analyticsReporter.logEvent( + AnalyticsEvents.PROFILE_SAVED, + mapOf( + AnalyticsEvents.PARAM_CHANGED_NAME to + (state.currentProfile.name != state.name), + AnalyticsEvents.PARAM_CHANGED_AVATAR to + (state.currentProfile.avatar != state.avatar), + ), + ) profileRepo.updateProfile(UserProfile(state.name, state.avatar)) postSideEffect(EditProfileScreenEffect.NavigateBack) } diff --git a/shared/src/commonMain/kotlin/dev/arkbuilders/drop/presentation/history/HistoryViewModel.kt b/shared/src/commonMain/kotlin/dev/arkbuilders/drop/presentation/history/HistoryViewModel.kt index e341085..57e2657 100644 --- a/shared/src/commonMain/kotlin/dev/arkbuilders/drop/presentation/history/HistoryViewModel.kt +++ b/shared/src/commonMain/kotlin/dev/arkbuilders/drop/presentation/history/HistoryViewModel.kt @@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dev.arkbuilders.drop.domain.model.TransferSession import dev.arkbuilders.drop.domain.repository.TransferSessionRepo +import dev.arkbuilders.drop.instrumentation.AnalyticsEvents +import dev.arkbuilders.drop.instrumentation.AnalyticsReporter import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import org.orbitmvi.orbit.Container @@ -20,6 +22,7 @@ sealed class HistoryScreenEffect class HistoryViewModel( private val historyItemRepository: TransferSessionRepo, + private val analyticsReporter: AnalyticsReporter, ) : ViewModel(), ContainerHost { override val container: Container = container( @@ -49,6 +52,10 @@ class HistoryViewModel( fun onClear() = intent { + analyticsReporter.logEvent( + AnalyticsEvents.HISTORY_CLEARED, + mapOf(AnalyticsEvents.PARAM_ITEM_COUNT to state.historyItems.size), + ) historyItemRepository.clearHistory() reduce { state.copy(showClearDialog = false) @@ -71,6 +78,7 @@ class HistoryViewModel( fun onDelete(id: Long) = intent { + analyticsReporter.logEvent(AnalyticsEvents.HISTORY_ITEM_DELETED) historyItemRepository.deleteSession(id) reduce { state.copy(showDeleteDialog = false) diff --git a/shared/src/commonMain/kotlin/dev/arkbuilders/drop/presentation/receive/ReceiveViewModel.kt b/shared/src/commonMain/kotlin/dev/arkbuilders/drop/presentation/receive/ReceiveViewModel.kt index 875686e..59c21da 100644 --- a/shared/src/commonMain/kotlin/dev/arkbuilders/drop/presentation/receive/ReceiveViewModel.kt +++ b/shared/src/commonMain/kotlin/dev/arkbuilders/drop/presentation/receive/ReceiveViewModel.kt @@ -1,3 +1,5 @@ +@file:OptIn(ExperimentalTime::class) + package dev.arkbuilders.drop.presentation.receive import androidx.lifecycle.ViewModel @@ -6,6 +8,9 @@ import co.touchlab.kermit.Logger import dev.arkbuilders.drop.data.helper.PermissionsHelper import dev.arkbuilders.drop.domain.model.ReceiveSession import dev.arkbuilders.drop.domain.repository.ReceiveSessionRepo +import dev.arkbuilders.drop.instrumentation.AnalyticsEvents +import dev.arkbuilders.drop.instrumentation.AnalyticsReporter +import dev.arkbuilders.drop.instrumentation.FirebaseReporter import kotlinx.coroutines.delay import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -13,24 +18,36 @@ import org.orbitmvi.orbit.Container import org.orbitmvi.orbit.ContainerHost import org.orbitmvi.orbit.blockingIntent import org.orbitmvi.orbit.viewmodel.container +import kotlin.time.Clock +import kotlin.time.ExperimentalTime class ReceiveViewModel( private val receiveSessionRepo: ReceiveSessionRepo, private val permissionsHelper: PermissionsHelper, + private val firebaseReporter: FirebaseReporter, + private val analyticsReporter: AnalyticsReporter, ) : ViewModel(), ContainerHost { override val container: Container = container(ReceiveScreenState.Initial(false)) + private var receiveStartedAtMs: Long? = null + private var receiveSenderConnectedLogged = false + private var receiveSource = AnalyticsEvents.SOURCE_UNKNOWN + init { + firebaseReporter.log("ReceiveViewModel: initialized") intent { + val granted = permissionsHelper.isCameraGranted() + firebaseReporter.log("ReceiveViewModel: initial camera permission granted=$granted") reduce { - ReceiveScreenState.Initial(permissionsHelper.isCameraGranted()) + ReceiveScreenState.Initial(granted) } } } fun onRequestCameraPermission() = intent { + firebaseReporter.log("ReceiveViewModel: requesting camera permission") reduce { ReceiveScreenState.RequestingPermission } @@ -39,6 +56,9 @@ class ReceiveViewModel( fun onEnterManually() = intent { + firebaseReporter.log("ReceiveViewModel: entering manual input mode") + receiveSource = AnalyticsEvents.SOURCE_MANUAL + analyticsReporter.logEvent(AnalyticsEvents.RECEIVE_MANUAL_INPUT_STARTED) reduce { ReceiveScreenState.ManualInput(inputText = "", inputError = null) } @@ -46,6 +66,9 @@ class ReceiveViewModel( fun onStartScanning() = intent { + firebaseReporter.log("ReceiveViewModel: starting QR scanner") + receiveSource = AnalyticsEvents.SOURCE_QR + analyticsReporter.logEvent(AnalyticsEvents.RECEIVE_SCAN_STARTED) reduce { ReceiveScreenState.Scanning } @@ -53,6 +76,7 @@ class ReceiveViewModel( fun onStopScanning() = intent { + firebaseReporter.log("ReceiveViewModel: stopping QR scanner") reduce { ReceiveScreenState.Initial(permissionsHelper.isCameraGranted()) } @@ -60,6 +84,14 @@ class ReceiveViewModel( fun onError(error: ReceiveError) = intent { + firebaseReporter.recordError("ReceiveViewModel: error state error=${error.name}", null) + analyticsReporter.logEvent( + AnalyticsEvents.RECEIVE_FAILED, + mapOf( + AnalyticsEvents.PARAM_PHASE to "ui", + AnalyticsEvents.PARAM_ERROR_TYPE to error.name.lowercase(), + ), + ) reduce { ReceiveScreenState.Error(error = error) } @@ -70,18 +102,34 @@ class ReceiveViewModel( try { val s = state if (s !is ReceiveScreenState.QRCodeScanned) { + firebaseReporter.log( + "ReceiveViewModel: onAccept ignored - not in QRCodeScanned state", + ) return@intent } val ticket = s.ticket val confirmation = s.confirmation + firebaseReporter.log("ReceiveViewModel: accept triggered") + receiveStartedAtMs = Clock.System.now().toEpochMilliseconds() + receiveSenderConnectedLogged = false + analyticsReporter.logEvent( + AnalyticsEvents.RECEIVE_STARTED, + mapOf(AnalyticsEvents.PARAM_SOURCE to receiveSource), + ) + reduce { ReceiveScreenState.Connecting } + firebaseReporter.log("ReceiveViewModel: calling receiveFiles") val session = receiveSessionRepo.receiveFiles(ticket, confirmation) if (session != null) { + firebaseReporter.log( + "ReceiveViewModel: " + + "session created successfully, transitioning to Receiving", + ) reduce { ReceiveScreenState.Receiving( session, @@ -90,11 +138,31 @@ class ReceiveViewModel( } listenToProgress(session) } else { + firebaseReporter.recordError( + "ReceiveViewModel: receiveFiles returned null session", + null, + ) + analyticsReporter.logEvent( + AnalyticsEvents.RECEIVE_FAILED, + mapOf( + AnalyticsEvents.PARAM_PHASE to "connection", + AnalyticsEvents.PARAM_ERROR_TYPE to "session_creation_failed", + ), + ) reduce { ReceiveScreenState.Error(error = ReceiveError.ConnectionFailed) } } } catch (e: Exception) { + firebaseReporter.recordError("ReceiveViewModel: onAccept exception", e) + analyticsReporter.logEvent( + AnalyticsEvents.RECEIVE_FAILED, + mapOf( + AnalyticsEvents.PARAM_PHASE to "connection", + AnalyticsEvents.PARAM_ERROR_TYPE to + (e::class.simpleName ?: "unknown_error"), + ), + ) val error = when { e.message?.contains( @@ -113,6 +181,11 @@ class ReceiveViewModel( fun onCameraPermissionGranted(isGranted: Boolean) = intent { + firebaseReporter.log("ReceiveViewModel: camera permission result granted=$isGranted") + analyticsReporter.logEvent( + AnalyticsEvents.CAMERA_PERMISSION_RESULT, + mapOf(AnalyticsEvents.PARAM_GRANTED to isGranted), + ) val state = if (isGranted) { ReceiveScreenState.Scanning @@ -126,6 +199,7 @@ class ReceiveViewModel( fun onScanAgain() = intent { + firebaseReporter.log("ReceiveViewModel: scan again") val state = if (permissionsHelper.isCameraGranted()) { ReceiveScreenState.Scanning @@ -139,8 +213,10 @@ class ReceiveViewModel( fun onReceiveMore() = intent { + firebaseReporter.log("ReceiveViewModel: receive more") val s = state if (s is ReceiveScreenState.Success) { + analyticsReporter.logEvent(AnalyticsEvents.RECEIVE_MORE_SELECTED) receiveSessionRepo.cancelReceive(s.session) } reduce { @@ -150,6 +226,7 @@ class ReceiveViewModel( fun onDone() = intent { + firebaseReporter.log("ReceiveViewModel: done - navigating back") val s = state if (s is ReceiveScreenState.Success) { receiveSessionRepo.cancelReceive(s.session) @@ -164,6 +241,13 @@ class ReceiveViewModel( return@intent if (!clipText.isNullOrEmpty()) { + firebaseReporter.log( + "ReceiveViewModel: pasting from clipboard length=${clipText.length}", + ) + analyticsReporter.logEvent( + AnalyticsEvents.RECEIVE_CLIPBOARD_PASTED, + mapOf(AnalyticsEvents.PARAM_HAS_TEXT to true), + ) reduce { s.copy( inputText = clipText, @@ -171,10 +255,17 @@ class ReceiveViewModel( ) } } + if (clipText.isNullOrEmpty()) { + analyticsReporter.logEvent( + AnalyticsEvents.RECEIVE_CLIPBOARD_PASTED, + mapOf(AnalyticsEvents.PARAM_HAS_TEXT to false), + ) + } } fun onErrorRetry() = intent { + firebaseReporter.log("ReceiveViewModel: error retry") reduce { ReceiveScreenState.Initial(permissionsHelper.isCameraGranted()) } @@ -184,6 +275,7 @@ class ReceiveViewModel( intent { val s = state if (s is ReceiveScreenState.Error) { + firebaseReporter.log("ReceiveViewModel: error dismissed error=${s.error.name}") s.session?.let { receiveSessionRepo.cancelReceive(it) } @@ -195,6 +287,12 @@ class ReceiveViewModel( ticket: String, confirmation: UByte, ) = intent { + firebaseReporter.log("ReceiveViewModel: QR code scanned") + receiveSource = AnalyticsEvents.SOURCE_QR + analyticsReporter.logEvent( + AnalyticsEvents.RECEIVE_CODE_ENTERED, + mapOf(AnalyticsEvents.PARAM_SOURCE to AnalyticsEvents.SOURCE_QR), + ) reduce { ReceiveScreenState.QRCodeScanned(ticket, confirmation) } @@ -216,8 +314,13 @@ class ReceiveViewModel( fun onCancelReceiving() = intent { + firebaseReporter.log("ReceiveViewModel: cancelling receiving") val s = state if (s is ReceiveScreenState.Receiving) { + analyticsReporter.logEvent( + AnalyticsEvents.RECEIVE_CANCELLED, + mapOf(AnalyticsEvents.PARAM_PHASE to "receiving"), + ) receiveSessionRepo.cancelReceive(s.session) } @@ -228,6 +331,11 @@ class ReceiveViewModel( fun onCancelManualInput() = intent { + firebaseReporter.log("ReceiveViewModel: cancelling manual input") + analyticsReporter.logEvent( + AnalyticsEvents.RECEIVE_CANCELLED, + mapOf(AnalyticsEvents.PARAM_PHASE to "manual_input"), + ) reduce { ReceiveScreenState.Initial(permissionsHelper.isCameraGranted()) } @@ -240,8 +348,15 @@ class ReceiveViewModel( if (s !is ReceiveScreenState.ManualInput) return@intent + firebaseReporter.log("ReceiveViewModel: manual input submitted") val parsed = parseManualInput(s.inputText) if (parsed != null) { + firebaseReporter.log("ReceiveViewModel: manual input parsed successfully") + receiveSource = AnalyticsEvents.SOURCE_MANUAL + analyticsReporter.logEvent( + AnalyticsEvents.RECEIVE_CODE_ENTERED, + mapOf(AnalyticsEvents.PARAM_SOURCE to AnalyticsEvents.SOURCE_MANUAL), + ) reduce { ReceiveScreenState.QRCodeScanned( ticket = parsed.first, @@ -250,6 +365,14 @@ class ReceiveViewModel( } postSideEffect(ReceiveScreenEffect.HideKeyboard) } else { + firebaseReporter.log("ReceiveViewModel: manual input parse failed") + analyticsReporter.logEvent( + AnalyticsEvents.RECEIVE_FAILED, + mapOf( + AnalyticsEvents.PARAM_PHASE to "manual_input", + AnalyticsEvents.PARAM_ERROR_TYPE to "invalid_code", + ), + ) reduce { s.copy( inputError = "Invalid format. Please enter: ticket confirmation", @@ -272,7 +395,32 @@ class ReceiveViewModel( } if (progress.isConnected && progress.files.isNotEmpty()) { + if (!receiveSenderConnectedLogged) { + receiveSenderConnectedLogged = true + val totalBytes = progress.files.sumOf { it.size.toLong() } + analyticsReporter.logEvent( + AnalyticsEvents.RECEIVE_SENDER_CONNECTED, + mapOf( + AnalyticsEvents.PARAM_FILE_COUNT to progress.files.size, + AnalyticsEvents.PARAM_TOTAL_BYTES to totalBytes, + AnalyticsEvents.PARAM_TOTAL_SIZE_BUCKET to + AnalyticsEvents.sizeBucket(totalBytes), + ), + ) + } + // Check if all files are complete + val completedCount = + progress.files.count { file -> + progress.fileProgress[file.id]?.isComplete == true + } + firebaseReporter.log( + "ReceiveViewModel: progress " + + "connected=${progress.isConnected} " + + "files=${progress.files.size} " + + "completed=$completedCount", + ) + val allFilesComplete = progress.files.all { file -> val fileProgress = progress.fileProgress[file.id] @@ -281,10 +429,33 @@ class ReceiveViewModel( if (allFilesComplete) { // Small delay to ensure UI updates are visible + firebaseReporter.log( + "ReceiveViewModel: " + + "all ${progress.files.size} files complete, saving...", + ) delay(1000) try { val savedFiles = receiveSessionRepo.saveReceivedFiles(session) if (savedFiles.isNotEmpty()) { + val totalBytes = progress.files.sumOf { it.size.toLong() } + firebaseReporter.log( + "ReceiveViewModel: saved ${savedFiles.size} files successfully", + ) + analyticsReporter.logEvent( + AnalyticsEvents.RECEIVE_COMPLETED, + mapOf( + AnalyticsEvents.PARAM_FILE_COUNT to + savedFiles.size, + AnalyticsEvents.PARAM_TOTAL_BYTES to totalBytes, + AnalyticsEvents.PARAM_TOTAL_SIZE_BUCKET to + AnalyticsEvents.sizeBucket(totalBytes), + AnalyticsEvents.PARAM_DURATION_MS to + AnalyticsEvents.durationSince( + receiveStartedAtMs, + Clock.System.now().toEpochMilliseconds(), + ), + ), + ) reduce { ReceiveScreenState.Success( session = session, @@ -292,6 +463,17 @@ class ReceiveViewModel( ) } } else { + firebaseReporter.recordError( + "ReceiveViewModel: no files received", + null, + ) + analyticsReporter.logEvent( + AnalyticsEvents.RECEIVE_FAILED, + mapOf( + AnalyticsEvents.PARAM_PHASE to "saving", + AnalyticsEvents.PARAM_ERROR_TYPE to "no_files_received", + ), + ) reduce { ReceiveScreenState.Error( session = session, @@ -301,6 +483,15 @@ class ReceiveViewModel( } } catch (e: Exception) { Logger.w("Save failed: ${e::class.simpleName} ${e.message}") + firebaseReporter.recordError("ReceiveViewModel: save failed", e) + analyticsReporter.logEvent( + AnalyticsEvents.RECEIVE_FAILED, + mapOf( + AnalyticsEvents.PARAM_PHASE to "saving", + AnalyticsEvents.PARAM_ERROR_TYPE to + (e::class.simpleName ?: "unknown_error"), + ), + ) val error = when { e.message?.contains("storage", ignoreCase = true) == true -> @@ -319,6 +510,10 @@ class ReceiveViewModel( } } } + } else if (progress.isConnected) { + firebaseReporter.log( + "ReceiveViewModel: connected to sender waiting for files", + ) } } }.launchIn(viewModelScope) diff --git a/shared/src/commonMain/kotlin/dev/arkbuilders/drop/presentation/send/SendViewModel.kt b/shared/src/commonMain/kotlin/dev/arkbuilders/drop/presentation/send/SendViewModel.kt index 708d1a4..2af9330 100644 --- a/shared/src/commonMain/kotlin/dev/arkbuilders/drop/presentation/send/SendViewModel.kt +++ b/shared/src/commonMain/kotlin/dev/arkbuilders/drop/presentation/send/SendViewModel.kt @@ -1,3 +1,5 @@ +@file:OptIn(ExperimentalTime::class) + package dev.arkbuilders.drop.presentation.send import androidx.lifecycle.ViewModel @@ -7,6 +9,9 @@ import dev.arkbuilders.drop.data.helper.NetworkStatus import dev.arkbuilders.drop.data.helper.ResourcesHelper import dev.arkbuilders.drop.domain.model.SendSession import dev.arkbuilders.drop.domain.repository.SendSessionRepo +import dev.arkbuilders.drop.instrumentation.AnalyticsEvents +import dev.arkbuilders.drop.instrumentation.AnalyticsReporter +import dev.arkbuilders.drop.instrumentation.FirebaseReporter import kotlinx.coroutines.delay import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -15,17 +20,30 @@ import kotlinx.coroutines.launch import org.orbitmvi.orbit.Container import org.orbitmvi.orbit.ContainerHost import org.orbitmvi.orbit.viewmodel.container +import kotlin.time.Clock +import kotlin.time.ExperimentalTime class SendViewModel( private val resourcesHelper: ResourcesHelper, private val networkStatus: NetworkStatus, private val sendSessionRepo: SendSessionRepo, + private val firebaseReporter: FirebaseReporter, + private val analyticsReporter: AnalyticsReporter, ) : ViewModel(), ContainerHost { override val container: Container = container(SendScreenState.FileSelection()) + private var sendStartedAtMs: Long? = null + private var sendReceiverConnectedLogged = false + + init { + firebaseReporter.log("SendViewModel: initialized") + } + fun onAddFiles() = intent { + firebaseReporter.log("SendViewModel: user tapped add files") + analyticsReporter.logEvent(AnalyticsEvents.SEND_FILE_PICKER_OPENED) postSideEffect(SendScreenEffect.LaunchFilePicker) } @@ -33,12 +51,35 @@ class SendViewModel( intent { val s = state if (s is SendScreenState.FileSelection) { + firebaseReporter.log( + "SendViewModel: files added - new: ${newFiles.size}, current: ${s.files.size}", + ) + val validated = resourcesHelper.validateUris(newFiles) val allFiles = s.files + validated.first val canStartTransfer = allFiles.isNotEmpty() && networkStatus.isOnline() val size = allFiles.sumOf { resourcesHelper.getFileSize(it) } + analyticsReporter.logEvent( + AnalyticsEvents.SEND_FILES_SELECTED, + mapOf( + AnalyticsEvents.PARAM_FILE_COUNT to allFiles.size, + AnalyticsEvents.PARAM_SKIPPED_COUNT to validated.second, + AnalyticsEvents.PARAM_TOTAL_BYTES to size, + AnalyticsEvents.PARAM_TOTAL_SIZE_BUCKET to AnalyticsEvents.sizeBucket(size), + ), + ) + + firebaseReporter.log( + "SendViewModel: files validated - " + + "valid: ${validated.first.size}, " + + "skipped: ${validated.second}, " + + "total: ${allFiles.size}, " + + "totalSize: $size bytes, " + + "canStart: $canStartTransfer", + ) + reduce { s.copy(files = allFiles, size = size, canStartTransfer = canStartTransfer) } @@ -51,6 +92,19 @@ class SendViewModel( if (s is SendScreenState.FileSelection) { val newFiles = s.files - file val size = newFiles.sumOf { resourcesHelper.getFileSize(it) } + analyticsReporter.logEvent( + AnalyticsEvents.SEND_FILE_REMOVED, + mapOf( + AnalyticsEvents.PARAM_REMAINING_FILE_COUNT to newFiles.size, + AnalyticsEvents.PARAM_TOTAL_BYTES to size, + AnalyticsEvents.PARAM_TOTAL_SIZE_BUCKET to AnalyticsEvents.sizeBucket(size), + ), + ) + firebaseReporter.log( + "SendViewModel: file removed - " + + "remaining: ${newFiles.size}, " + + "remainingSize: $size bytes", + ) reduce { s.copy(files = newFiles, size = size) } @@ -61,14 +115,47 @@ class SendViewModel( intent { val s = state if (s !is SendScreenState.FileSelection) { + firebaseReporter.log( + "SendViewModel: start transfer ignored - wrong state: ${s::class.simpleName}", + ) return@intent } + + firebaseReporter.setCustomKey("send_file_count", s.files.size.toString()) + firebaseReporter.setCustomKey("send_total_bytes", s.size.toString()) + firebaseReporter.log( + "SendViewModel: starting transfer - " + + "files: ${s.files.size}, " + + "totalSize: ${s.size} bytes, " + + "online: ${networkStatus.isOnline()}", + ) + sendStartedAtMs = Clock.System.now().toEpochMilliseconds() + sendReceiverConnectedLogged = false + analyticsReporter.logEvent( + AnalyticsEvents.SEND_STARTED, + mapOf( + AnalyticsEvents.PARAM_FILE_COUNT to s.files.size, + AnalyticsEvents.PARAM_TOTAL_BYTES to s.size, + AnalyticsEvents.PARAM_TOTAL_SIZE_BUCKET to AnalyticsEvents.sizeBucket(s.size), + ), + ) + reduce { SendScreenState.GeneratingQR(s.files) } val session = sendSessionRepo.sendFiles(s.files) if (session == null) { + firebaseReporter.recordError( + "SendViewModel: session creation failed - could not initialize transfer", + ) + analyticsReporter.logEvent( + AnalyticsEvents.SEND_FAILED, + mapOf( + AnalyticsEvents.PARAM_PHASE to "initialization", + AnalyticsEvents.PARAM_ERROR_TYPE to "session_creation_failed", + ), + ) reduce { SendScreenState.Error( files = s.files, @@ -79,8 +166,19 @@ class SendViewModel( } val ticket = session.bubble.getTicket() val confirmation = session.bubble.getConfirmation() + firebaseReporter.log("SendViewModel: session created") if (ticket.isEmpty()) { + firebaseReporter.recordError( + "SendViewModel: empty transfer code received from bridge", + ) + analyticsReporter.logEvent( + AnalyticsEvents.SEND_FAILED, + mapOf( + AnalyticsEvents.PARAM_PHASE to "initialization", + AnalyticsEvents.PARAM_ERROR_TYPE to "empty_ticket", + ), + ) reduce { SendScreenState.Error( session = session, @@ -92,8 +190,17 @@ class SendViewModel( } val copyString = "${session.bubble.getTicket()} ${session.bubble.getConfirmation()}" + firebaseReporter.log("SendViewModel: generating QR code") val qrBitmap = resourcesHelper.generateQRCode(ticket, confirmation) if (qrBitmap == null) { + firebaseReporter.recordError("SendViewModel: QR generation failed") + analyticsReporter.logEvent( + AnalyticsEvents.SEND_FAILED, + mapOf( + AnalyticsEvents.PARAM_PHASE to "qr_generation", + AnalyticsEvents.PARAM_ERROR_TYPE to "qr_generation_failed", + ), + ) reduce { SendScreenState.Error( session = session, @@ -103,8 +210,21 @@ class SendViewModel( } return@intent } + firebaseReporter.log( + "SendViewModel: QR code generated successfully - size: ${qrBitmap.size} bytes", + ) + analyticsReporter.logEvent( + AnalyticsEvents.SEND_QR_READY, + mapOf( + AnalyticsEvents.PARAM_FILE_COUNT to s.files.size, + AnalyticsEvents.PARAM_TOTAL_BYTES to s.size, + AnalyticsEvents.PARAM_TOTAL_SIZE_BUCKET to AnalyticsEvents.sizeBucket(s.size), + ), + ) + listenToSendProgress(session) monitorTransferCompletion(session) + firebaseReporter.log("SendViewModel: waiting for receiver") reduce { SendScreenState.WaitingForReceiver( session = session, @@ -118,13 +238,37 @@ class SendViewModel( fun onCancelTransfer() = intent { val s = state + var cancelPhase: String? = null val session = when (s) { - is SendScreenState.WaitingForReceiver -> s.session - is SendScreenState.Transfer -> s.session - else -> null + is SendScreenState.WaitingForReceiver -> { + cancelPhase = "waiting_for_receiver" + firebaseReporter.log("SendViewModel: user cancelled while waiting") + s.session + } + + is SendScreenState.Transfer -> { + cancelPhase = "transfer" + firebaseReporter.log("SendViewModel: user cancelled during transfer") + s.session + } + + else -> { + firebaseReporter.log( + "SendViewModel: cancel ignored - state: ${s::class.simpleName}", + ) + null + } } + cancelPhase?.let { + analyticsReporter.logEvent( + AnalyticsEvents.SEND_CANCELLED, + mapOf(AnalyticsEvents.PARAM_PHASE to it), + ) + } session?.let { sendSessionRepo.cancelSend(it) } + firebaseReporter.setCustomKey("send_file_count", "0") + firebaseReporter.setCustomKey("send_total_bytes", "0") postSideEffect(SendScreenEffect.NavigateBack) } @@ -133,6 +277,11 @@ class SendViewModel( val s = state val files = if (s is SendScreenState.GeneratingQR) { + firebaseReporter.log("SendViewModel: user cancelled QR generation") + analyticsReporter.logEvent( + AnalyticsEvents.SEND_CANCELLED, + mapOf(AnalyticsEvents.PARAM_PHASE to "qr_generation"), + ) s.files } else { emptyList() @@ -147,6 +296,32 @@ class SendViewModel( intent { val s = state if (s is SendScreenState.Transfer) { + val progress = s.session.subscriber.progress.value + firebaseReporter.log( + "SendViewModel: transfer completed - " + + "files: ${s.files.size}, " + + "sent: ${progress.sent}, " + + "remaining: ${progress.remaining}", + ) + firebaseReporter.setCustomKey( + "send_completed_at", + Clock.System.now().toEpochMilliseconds().toString(), + ) + analyticsReporter.logEvent( + AnalyticsEvents.SEND_COMPLETED, + mapOf( + AnalyticsEvents.PARAM_FILE_COUNT to s.files.size, + AnalyticsEvents.PARAM_TOTAL_BYTES to s.totalBytes, + AnalyticsEvents.PARAM_TOTAL_SIZE_BUCKET to + AnalyticsEvents.sizeBucket(s.totalBytes), + AnalyticsEvents.PARAM_DURATION_MS to + AnalyticsEvents.durationSince( + sendStartedAtMs, + Clock.System.now().toEpochMilliseconds(), + ), + ), + ) + sendSessionRepo.recordSendCompletion( s.files, s.session, @@ -161,8 +336,11 @@ class SendViewModel( intent { val s = state if (s is SendScreenState.Complete) { + firebaseReporter.log("SendViewModel: user done - ${s.files.size} files sent") sendSessionRepo.cancelSend(s.session) } + firebaseReporter.setCustomKey("send_file_count", "0") + firebaseReporter.setCustomKey("send_total_bytes", "0") postSideEffect(SendScreenEffect.NavigateBack) } @@ -170,8 +348,12 @@ class SendViewModel( intent { val s = state if (s is SendScreenState.Complete) { + firebaseReporter.log("SendViewModel: user wants to send more files") + analyticsReporter.logEvent(AnalyticsEvents.SEND_MORE_SELECTED) sendSessionRepo.cancelSend(s.session) } + firebaseReporter.setCustomKey("send_file_count", "0") + firebaseReporter.setCustomKey("send_total_bytes", "0") reduce { SendScreenState.FileSelection() } @@ -180,6 +362,11 @@ class SendViewModel( fun onErrorRetry() = intent { val s = state + firebaseReporter.log( + "SendViewModel: user retrying after error - " + + "errorType: ${(s as? SendScreenState.Error)?.error}", + ) + if (s is SendScreenState.Error) { s.session?.let { sendSessionRepo.cancelSend(it) @@ -195,6 +382,12 @@ class SendViewModel( val canStartTransfer = validated.isNotEmpty() && networkStatus.isOnline() val size = validated.sumOf { resourcesHelper.getFileSize(it) } + firebaseReporter.log( + "SendViewModel: retry with " + + "${validated.size} valid files, " + + "canStart: $canStartTransfer", + ) + reduce { SendScreenState.FileSelection( files = validated, @@ -208,14 +401,21 @@ class SendViewModel( intent { val s = state if (s is SendScreenState.Error) { + firebaseReporter.log("SendViewModel: user dismissed error - errorType: ${s.error}") s.session?.let { sendSessionRepo.cancelSend(it) } } + firebaseReporter.setCustomKey("send_file_count", "0") + firebaseReporter.setCustomKey("send_total_bytes", "0") postSideEffect(SendScreenEffect.NavigateBack) } private fun listenToSendProgress(session: SendSession) { + val chunkBytes = 10L * 1024L * 1024L + var lastSent = -1L + var lastLoggedChunk = -1L + session.subscriber.progress.onEach { progress -> intent { val s = state @@ -227,9 +427,44 @@ class SendViewModel( } try { + val wasConnected = (s as? SendScreenState.Transfer)?.isConnected ?: false + val justConnected = progress.isConnected && !wasConnected + if (progress.isConnected.not()) return@intent + if (justConnected) { + val totalBytes = progress.sent.toLong() + progress.remaining.toLong() + firebaseReporter.log( + "SendViewModel: receiver connected - " + + "fileCount: ${files.size}", + ) + if (!sendReceiverConnectedLogged) { + sendReceiverConnectedLogged = true + analyticsReporter.logEvent( + AnalyticsEvents.SEND_RECEIVER_CONNECTED, + mapOf( + AnalyticsEvents.PARAM_FILE_COUNT to files.size, + AnalyticsEvents.PARAM_TOTAL_BYTES to totalBytes, + ), + ) + } + } + + val sent = progress.sent.toLong() + val fileReset = sent < lastSent + lastSent = sent + + val currentChunk = sent / chunkBytes + if (fileReset || currentChunk > lastLoggedChunk) { + lastLoggedChunk = currentChunk + firebaseReporter.log( + "SendViewModel: transfer progress - " + + "sent: ${progress.sent}, " + + "remaining: ${progress.remaining}", + ) + } + val transfer = SendScreenState.Transfer( session = session, @@ -248,6 +483,19 @@ class SendViewModel( transfer } } catch (e: Throwable) { + firebaseReporter.recordError( + "SendViewModel: transfer progress error - " + + "${e::class.simpleName}: ${e.message}", + e, + ) + analyticsReporter.logEvent( + AnalyticsEvents.SEND_FAILED, + mapOf( + AnalyticsEvents.PARAM_PHASE to "transfer", + AnalyticsEvents.PARAM_ERROR_TYPE to + (e::class.simpleName ?: "unknown_error"), + ), + ) Logger.e("Transfer interrupted: ${e::class.simpleName} ${e.message}") reduce { SendScreenState.Error( @@ -263,9 +511,12 @@ class SendViewModel( private fun monitorTransferCompletion(session: SendSession) { viewModelScope.launch { + firebaseReporter.log("SendViewModel: monitoring transfer completion") + while (coroutineContext.isActive) { val isFinished = session.bubble.isFinished() if (isFinished) { + firebaseReporter.log("SendViewModel: transfer finished signal detected") onComplete() break } diff --git a/shared/src/iosMain/kotlin/dev/arkbuilders/drop/data/helper/AvatarHelper.ios.kt b/shared/src/iosMain/kotlin/dev/arkbuilders/drop/data/helper/AvatarHelper.ios.kt index 9c1618b..8761b0c 100644 --- a/shared/src/iosMain/kotlin/dev/arkbuilders/drop/data/helper/AvatarHelper.ios.kt +++ b/shared/src/iosMain/kotlin/dev/arkbuilders/drop/data/helper/AvatarHelper.ios.kt @@ -1,11 +1,82 @@ +@file:OptIn(ExperimentalForeignApi::class) + package dev.arkbuilders.drop.data.helper +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.useContents +import platform.CoreGraphics.CGRectMake +import platform.CoreGraphics.CGSizeMake +import platform.Foundation.NSData +import platform.Foundation.NSURL +import platform.Foundation.base64EncodedStringWithOptions +import platform.Foundation.dataWithContentsOfURL +import platform.UIKit.UIGraphicsBeginImageContextWithOptions +import platform.UIKit.UIGraphicsEndImageContext +import platform.UIKit.UIGraphicsGetImageFromCurrentImageContext +import platform.UIKit.UIImage +import platform.UIKit.UIImageJPEGRepresentation + actual class AvatarHelper { actual fun uriToBase64(uri: String): String? { - throw NotImplementedError() + return try { + val imageUrl = NSURL.URLWithString(uri) ?: return null + val imageData = NSData.dataWithContentsOfURL(imageUrl) ?: return null + val image = UIImage.imageWithData(imageData) ?: return null + + val optimizedImage = optimizeImage(image) ?: return null + + val jpegData = + UIImageJPEGRepresentation( + optimizedImage, + JPEG_QUALITY / 100.0, + ) ?: return null + + if (jpegData.length.toULong() > MAX_FILE_SIZE) { + return null + } + + jpegData.base64EncodedStringWithOptions(0u) + } catch (_: Throwable) { + null + } + } + + private fun optimizeImage(image: UIImage): UIImage? { + val (width, height) = + image.size.useContents { + Pair(width, height) + } + + val scaleFactor = + if (width > height) { + MAX_IMAGE_SIZE / width + } else { + MAX_IMAGE_SIZE / height + } + + return if (scaleFactor < 1.0) { + val newWidth = width * scaleFactor + val newHeight = height * scaleFactor + + val newSize = CGSizeMake(newWidth, newHeight) + UIGraphicsBeginImageContextWithOptions(newSize, false, 1.0) + image.drawInRect(CGRectMake(0.0, 0.0, newWidth, newHeight)) + val resizedImage = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + resizedImage + } else { + image + } } actual fun getDefaultAvatarBase64(avatarId: String): String { - throw NotImplementedError() + // Requires bundle resource mapping + return "" + } + + companion object { + const val MAX_IMAGE_SIZE = 512.0 + const val JPEG_QUALITY = 85.0 + const val MAX_FILE_SIZE: ULong = 512000uL // 500 KB (500 * 1024) } } diff --git a/shared/src/iosMain/kotlin/dev/arkbuilders/drop/data/helper/NetworkStatus.ios.kt b/shared/src/iosMain/kotlin/dev/arkbuilders/drop/data/helper/NetworkStatus.ios.kt index 060f6a3..0eed731 100644 --- a/shared/src/iosMain/kotlin/dev/arkbuilders/drop/data/helper/NetworkStatus.ios.kt +++ b/shared/src/iosMain/kotlin/dev/arkbuilders/drop/data/helper/NetworkStatus.ios.kt @@ -1,14 +1,108 @@ +@file:OptIn(ExperimentalForeignApi::class) + package dev.arkbuilders.drop.data.helper +import kotlinx.cinterop.COpaquePointer +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.alloc +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.cinterop.staticCFunction +import kotlinx.cinterop.value +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import platform.SystemConfiguration.SCNetworkReachabilityContext +import platform.SystemConfiguration.SCNetworkReachabilityCreateWithName +import platform.SystemConfiguration.SCNetworkReachabilityFlags +import platform.SystemConfiguration.SCNetworkReachabilityFlagsVar +import platform.SystemConfiguration.SCNetworkReachabilityGetFlags +import platform.SystemConfiguration.SCNetworkReachabilityRef +import platform.SystemConfiguration.SCNetworkReachabilitySetCallback +import platform.SystemConfiguration.SCNetworkReachabilitySetDispatchQueue +import platform.SystemConfiguration.kSCNetworkReachabilityFlagsConnectionRequired +import platform.SystemConfiguration.kSCNetworkReachabilityFlagsReachable +import platform.darwin.dispatch_queue_create actual class NetworkStatus { - actual fun isOnline(): Boolean { - throw NotImplementedError() + private val kReachable: UInt = kSCNetworkReachabilityFlagsReachable + private val kConnectionRequired: UInt = kSCNetworkReachabilityFlagsConnectionRequired + + private val reachability: SCNetworkReachabilityRef? = + SCNetworkReachabilityCreateWithName(null, "www.google.com") + + private val _onlineStatus = MutableStateFlow(checkIsOnline()) + actual val onlineStatus: StateFlow = _onlineStatus.asStateFlow() + + private companion object { + var currentInstance: NetworkStatus? = null + } + + init { + currentInstance = this + + val callback = + staticCFunction< + SCNetworkReachabilityRef?, + SCNetworkReachabilityFlags, + COpaquePointer?, + Unit, + > { _, flags, _ -> + val reachable = (flags and kSCNetworkReachabilityFlagsReachable) != 0u + val noConnectionRequired = + (flags and kSCNetworkReachabilityFlagsConnectionRequired) == 0u + + val isOnline = reachable && noConnectionRequired + currentInstance?._onlineStatus?.tryEmit(isOnline) + } + + memScoped { + val context = + alloc { + version = 0 + info = null + retain = null + release = null + copyDescription = null + } + + SCNetworkReachabilitySetCallback( + reachability, + callback, + context.ptr, + ) + + val queue = + dispatch_queue_create( + "NetworkStatusQueue", + null, + ) + + SCNetworkReachabilitySetDispatchQueue( + reachability, + queue, + ) + } } - actual val onlineStatus: StateFlow - get() { - throw NotImplementedError() + actual fun isOnline(): Boolean = onlineStatus.value + + private fun checkIsOnline(): Boolean = + memScoped { + val flags = alloc() + + if ( + reachability != null && + SCNetworkReachabilityGetFlags(reachability, flags.ptr) + ) { + val reachable = + (flags.value and kSCNetworkReachabilityFlagsReachable) != 0u + val noConnectionRequired = + (flags.value and kSCNetworkReachabilityFlagsConnectionRequired) == 0u + + reachable && noConnectionRequired + } else { + false + } } } diff --git a/shared/src/iosMain/kotlin/dev/arkbuilders/drop/data/helper/PermissionsHelper.ios.kt b/shared/src/iosMain/kotlin/dev/arkbuilders/drop/data/helper/PermissionsHelper.ios.kt index cd3b48a..24aefe1 100644 --- a/shared/src/iosMain/kotlin/dev/arkbuilders/drop/data/helper/PermissionsHelper.ios.kt +++ b/shared/src/iosMain/kotlin/dev/arkbuilders/drop/data/helper/PermissionsHelper.ios.kt @@ -1,11 +1,26 @@ +@file:OptIn(ExperimentalForeignApi::class) + package dev.arkbuilders.drop.data.helper +import kotlinx.cinterop.ExperimentalForeignApi +import platform.AVFoundation.AVAuthorizationStatusAuthorized +import platform.AVFoundation.AVCaptureDevice +import platform.AVFoundation.AVMediaTypeVideo +import platform.AVFoundation.authorizationStatusForMediaType + actual class PermissionsHelper { actual fun isCameraGranted(): Boolean { - throw NotImplementedError() + return when ( + AVCaptureDevice.authorizationStatusForMediaType(AVMediaTypeVideo) + ) { + AVAuthorizationStatusAuthorized -> true + else -> false + } } actual fun isWritePermissionGranted(): Boolean { - throw NotImplementedError() + // iOS photo/library write access is managed via Photos framework + // App sandbox writes (Documents/tmp) do not require permission + return true } } diff --git a/shared/src/iosMain/kotlin/dev/arkbuilders/drop/data/helper/ResourcesHelper.ios.kt b/shared/src/iosMain/kotlin/dev/arkbuilders/drop/data/helper/ResourcesHelper.ios.kt index 7054be8..92e9f8d 100644 --- a/shared/src/iosMain/kotlin/dev/arkbuilders/drop/data/helper/ResourcesHelper.ios.kt +++ b/shared/src/iosMain/kotlin/dev/arkbuilders/drop/data/helper/ResourcesHelper.ios.kt @@ -1,35 +1,290 @@ +@file:OptIn(ExperimentalForeignApi::class) + package dev.arkbuilders.drop.data.helper +import dev.arkbuilders.drop.bridge.crashlytics_log +import dev.arkbuilders.drop.bridge.crashlytics_recordError +import dev.arkbuilders.drop.domain.libwrapper.send.SenderFileDataImpl import dev.arkbuilders.drop.domain.libwrapper.send.request.DropSenderFileData +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.useContents +import kotlinx.cinterop.usePinned +import platform.CoreGraphics.CGAffineTransformMakeScale +import platform.CoreImage.CIContext +import platform.CoreImage.CIFilter +import platform.CoreImage.createCGImage +import platform.CoreImage.filterWithName +import platform.Foundation.NSData +import platform.Foundation.NSDate +import platform.Foundation.NSDocumentDirectory +import platform.Foundation.NSFileManager +import platform.Foundation.NSNumber +import platform.Foundation.NSString +import platform.Foundation.NSURL +import platform.Foundation.NSURLFileSizeKey +import platform.Foundation.NSUTF8StringEncoding +import platform.Foundation.NSUserDomainMask +import platform.Foundation.data +import platform.Foundation.dataUsingEncoding +import platform.Foundation.dataWithBytes +import platform.Foundation.getBytes +import platform.Foundation.setValue +import platform.Foundation.timeIntervalSince1970 +import platform.Foundation.writeToURL +import platform.UIKit.UIImage +import platform.UIKit.UIImagePNGRepresentation actual class ResourcesHelper { actual fun getFileName(uri: String): String? { - throw NotImplementedError() + return try { + val url = + NSURL.fileURLWithPath(uri) + ?: NSURL.URLWithString(uri) + ?: return null + val name = url.lastPathComponent + crashlytics_log("ResourcesHelper: getFileName succeeded") + name + } catch (e: Exception) { + crashlytics_recordError("ResourcesHelper: getFileName failed", e.message) + null + } } actual fun validateUris(uris: List): Pair, Int> { - throw NotImplementedError() + val validFiles = mutableListOf() + var skippedCount = 0 + + uris.forEach { uri -> + try { + val size = getFileSize(uri) + if (size in 1..2_000_000_000L) { // 2GB limit + validFiles.add(uri) + crashlytics_log( + "ResourcesHelper: validateUris - valid file size=$size bytes", + ) + } else { + crashlytics_log( + "ResourcesHelper: validateUris - skipped (size out of range): " + + "size=$size", + ) + skippedCount++ + } + } catch (_: Exception) { + crashlytics_log("ResourcesHelper: validateUris - skipped (exception)") + skippedCount++ + } + } + + crashlytics_log( + "ResourcesHelper: validateUris complete - " + + "valid: ${validFiles.size}, skipped: $skippedCount", + ) + return validFiles to skippedCount } actual fun getFileSize(uri: String): Long { - throw NotImplementedError() + return try { + val url = + NSURL.fileURLWithPath(uri) + ?: NSURL.URLWithString(uri) + ?: return 0L + val resourceValues = url.resourceValuesForKeys(listOf(NSURLFileSizeKey), null) + resourceValues?.get(NSURLFileSizeKey)?.let { + (it as? NSNumber)?.longValue ?: 0L + } ?: 0L + } catch (_: Exception) { + 0L + } } actual fun saveFileToDownloads( fileName: String, data: ByteArray, ): String? { - throw NotImplementedError() + val uniqueName = getUniqueFileName(fileName) + crashlytics_log( + "ResourcesHelper: saveFileToDownloads - " + + "dataSize=${data.size}", + ) + + return try { + val fileManager = NSFileManager.defaultManager + val documentsPath = + fileManager.URLForDirectory( + directory = NSDocumentDirectory, + inDomain = NSUserDomainMask, + appropriateForURL = null, + create = true, + error = null, + ) + if (documentsPath == null) { + crashlytics_recordError( + "ResourcesHelper: saveFileToDownloads - " + + "failed to get documents directory", + null, + ) + return null + } + + val fileURL = documentsPath.URLByAppendingPathComponent(uniqueName) + if (fileURL == null) { + crashlytics_recordError( + "ResourcesHelper: saveFileToDownloads - failed to create file URL", + null, + ) + return null + } + + val nsData = + data.usePinned { pinned -> + NSData.dataWithBytes(pinned.addressOf(0), data.size.toULong()) + } + val success = nsData.writeToURL(fileURL, atomically = true) + if (success) { + crashlytics_log("ResourcesHelper: saveFileToDownloads - saved successfully") + } else { + crashlytics_recordError( + "ResourcesHelper: saveFileToDownloads - writeToURL returned false", + null, + ) + } + + uniqueName + } catch (e: Exception) { + crashlytics_recordError("ResourcesHelper: saveFileToDownloads - exception", e.message) + null + } } actual fun generateQRCode( ticket: String, confirmation: UByte, ): ByteArray? { - throw NotImplementedError() + crashlytics_log("ResourcesHelper: generateQRCode") + return try { + if (ticket.isEmpty()) { + crashlytics_recordError( + "ResourcesHelper: generateQRCode - empty transfer code", + null, + ) + throw IllegalArgumentException("Ticket cannot be empty") + } + + val qrData = "drop://receive?ticket=$ticket&confirmation=$confirmation" + val data = qrData.encodeToNSData() + + val filter = CIFilter.filterWithName("CIQRCodeGenerator") + filter?.setValue(data, forKey = "inputMessage") + + // Scale up the QR code for better quality + val outputImage = filter?.outputImage + if (outputImage == null) { + crashlytics_recordError( + "ResourcesHelper: generateQRCode - " + + "CIFilter returned nil outputImage", + null, + ) + return null + } + + val extent = outputImage.extent + val scale = 512.0 / extent.useContents { size.width } + val transform = CGAffineTransformMakeScale(scale, scale) + val scaledImage = outputImage.imageByApplyingTransform(transform) + + // Convert CIImage to UIImage then to PNG data + val context = CIContext.context() + val scaledExtent = scaledImage.extent + val cgImage = context.createCGImage(scaledImage, fromRect = scaledExtent) + val uiImage = UIImage.imageWithCGImage(cgImage) + val pngData = UIImagePNGRepresentation(uiImage) + + val result = + pngData?.let { + val length = it.length.toInt() + val bytes = ByteArray(length) + bytes.usePinned { pinned -> + it.getBytes(pinned.addressOf(0), length = length.toULong()) + } + crashlytics_log( + "ResourcesHelper: generateQRCode - success, PNG size: $length bytes", + ) + bytes + } + + if (result == null) { + crashlytics_recordError( + "ResourcesHelper: generateQRCode - PNG conversion returned nil", + null, + ) + } + + result + } catch (e: Throwable) { + crashlytics_recordError("ResourcesHelper: generateQRCode - exception", e.message) + null + } } actual fun mapToSenderFileData(uri: String): DropSenderFileData { - throw NotImplementedError() + crashlytics_log("ResourcesHelper: mapToSenderFileData") + return SenderFileDataImpl(uri) } + + private fun getUniqueFileName(originalName: String): String { + val baseName = originalName.substringBeforeLast(".") + val ext = originalName.substringAfterLast(".", "") + var candidateName = originalName + var attempt = 1 + + while (true) { + if (!doesFileExist(candidateName)) { + return candidateName + } + + candidateName = + if (ext.isNotEmpty()) { + "$baseName($attempt).$ext" + } else { + "$baseName($attempt)" + } + + attempt++ + + if (attempt > 1000) { + val ts = NSDate().timeIntervalSince1970.toLong() + return if (ext.isNotEmpty()) { + "${baseName}_$ts.$ext" + } else { + "${baseName}_$ts" + } + } + } + } + + private fun doesFileExist(name: String): Boolean { + return try { + val fileManager = NSFileManager.defaultManager + val documentsPath = + fileManager.URLForDirectory( + directory = NSDocumentDirectory, + inDomain = NSUserDomainMask, + appropriateForURL = null, + create = false, + error = null, + ) ?: return false + + val fileURL = documentsPath.URLByAppendingPathComponent(name) + fileManager.fileExistsAtPath(fileURL?.path ?: "") + } catch (e: Exception) { + false + } + } +} + +// Helper extension functions +private fun String.encodeToNSData(): NSData { + val nsString = this as NSString + return nsString.dataUsingEncoding(NSUTF8StringEncoding) ?: NSData.data() } diff --git a/shared/src/iosMain/kotlin/dev/arkbuilders/drop/di/KoinHelper.kt b/shared/src/iosMain/kotlin/dev/arkbuilders/drop/di/KoinHelper.kt new file mode 100644 index 0000000..3ba86c8 --- /dev/null +++ b/shared/src/iosMain/kotlin/dev/arkbuilders/drop/di/KoinHelper.kt @@ -0,0 +1,72 @@ +package dev.arkbuilders.drop.di + +import dev.arkbuilders.drop.instrumentation.AnalyticsEvents +import dev.arkbuilders.drop.instrumentation.AnalyticsReporter +import dev.arkbuilders.drop.instrumentation.FirebaseReporter +import dev.arkbuilders.drop.presentation.edit.EditProfileViewModel +import dev.arkbuilders.drop.presentation.history.HistoryViewModel +import dev.arkbuilders.drop.presentation.home.HomeViewModel +import dev.arkbuilders.drop.presentation.receive.ReceiveViewModel +import dev.arkbuilders.drop.presentation.send.SendViewModel +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +/** + * Helper object to expose Koin dependencies to iOS + * SKIE will make these accessible from Swift + */ +object KoinHelper : KoinComponent { + fun getHomeViewModel(): HomeViewModel { + val viewModel: HomeViewModel by inject() + return viewModel + } + + fun getSendViewModel(): SendViewModel { + val viewModel: SendViewModel by inject() + return viewModel + } + + fun getReceiveViewModel(): ReceiveViewModel { + val viewModel: ReceiveViewModel by inject() + return viewModel + } + + fun getEditProfileViewModel(): EditProfileViewModel { + val viewModel: EditProfileViewModel by inject() + return viewModel + } + + fun getHistoryViewModel(): HistoryViewModel { + val viewModel: HistoryViewModel by inject() + return viewModel + } + + fun getFirebaseReporter(): FirebaseReporter { + val reporter: FirebaseReporter by inject() + return reporter + } + + fun logAppStart(platform: String) { + val reporter: AnalyticsReporter by inject() + reporter.logEvent( + AnalyticsEvents.APP_START, + mapOf(AnalyticsEvents.PARAM_PLATFORM to platform), + ) + } + + fun logScreenView(screenName: String) { + val reporter: AnalyticsReporter by inject() + reporter.logEvent( + AnalyticsEvents.SCREEN_VIEW, + mapOf( + AnalyticsEvents.PARAM_SCREEN_NAME to screenName, + AnalyticsEvents.PARAM_SCREEN_CLASS to screenName, + ), + ) + } + + fun logSendCodeCopied() { + val reporter: AnalyticsReporter by inject() + reporter.logEvent(AnalyticsEvents.SEND_CODE_COPIED) + } +} diff --git a/shared/src/iosMain/kotlin/dev/arkbuilders/drop/di/KoinInitializer.kt b/shared/src/iosMain/kotlin/dev/arkbuilders/drop/di/KoinInitializer.kt new file mode 100644 index 0000000..302b4ae --- /dev/null +++ b/shared/src/iosMain/kotlin/dev/arkbuilders/drop/di/KoinInitializer.kt @@ -0,0 +1,18 @@ +package dev.arkbuilders.drop.di + +import org.koin.core.context.startKoin +import org.koin.dsl.KoinAppDeclaration + +/** + * Initialize Koin for iOS + * This should be called from Swift on app startup + */ +fun initKoin(appDeclaration: KoinAppDeclaration = {}) { + startKoin { + appDeclaration() + modules(appModule) + } +} + +// Empty function for SwiftUI to call +fun doInitKoin() = initKoin() diff --git a/shared/src/iosMain/kotlin/dev/arkbuilders/drop/di/PlatformModule.ios.kt b/shared/src/iosMain/kotlin/dev/arkbuilders/drop/di/PlatformModule.ios.kt index dadf250..d41ca67 100644 --- a/shared/src/iosMain/kotlin/dev/arkbuilders/drop/di/PlatformModule.ios.kt +++ b/shared/src/iosMain/kotlin/dev/arkbuilders/drop/di/PlatformModule.ios.kt @@ -7,8 +7,14 @@ import androidx.datastore.preferences.core.Preferences import androidx.room.Room import androidx.sqlite.driver.bundled.BundledSQLiteDriver import dev.arkbuilders.drop.data.db.DropDatabase +import dev.arkbuilders.drop.data.helper.AvatarHelper +import dev.arkbuilders.drop.data.helper.NetworkStatus +import dev.arkbuilders.drop.data.helper.PermissionsHelper +import dev.arkbuilders.drop.data.helper.ResourcesHelper import dev.arkbuilders.drop.data.settings.DATASTORE_FILENAME import dev.arkbuilders.drop.data.settings.createDataStore +import dev.arkbuilders.drop.instrumentation.AnalyticsReporter +import dev.arkbuilders.drop.instrumentation.FirebaseReporter import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -20,6 +26,12 @@ import platform.Foundation.NSUserDomainMask actual val platformModule: Module = module { + single { AvatarHelper() } + single { NetworkStatus() } + single { PermissionsHelper() } + single { ResourcesHelper() } + single { FirebaseReporter() } + single { AnalyticsReporter() } single { val dbFilePath = documentDirectory() + "/${DropDatabase.DB_NAME}" diff --git a/shared/src/iosMain/kotlin/dev/arkbuilders/drop/domain/libwrapper/DropApi.ios.kt b/shared/src/iosMain/kotlin/dev/arkbuilders/drop/domain/libwrapper/DropApi.ios.kt index d6537eb..5e6eabb 100644 --- a/shared/src/iosMain/kotlin/dev/arkbuilders/drop/domain/libwrapper/DropApi.ios.kt +++ b/shared/src/iosMain/kotlin/dev/arkbuilders/drop/domain/libwrapper/DropApi.ios.kt @@ -1,5 +1,47 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + package dev.arkbuilders.drop.domain.libwrapper +import dev.arkbuilders.drop.bridge.crashlytics_log +import dev.arkbuilders.drop.domain.libwrapper.bridge.ArkDropBridgeWrapper +import dev.arkbuilders.drop.domain.libwrapper.receive.DropReceiveFilesBubble +import dev.arkbuilders.drop.domain.libwrapper.receive.DropReceiveFilesSubscriber +import dev.arkbuilders.drop.domain.libwrapper.receive.DropReceiveFilesSubscriberImpl +import dev.arkbuilders.drop.domain.libwrapper.receive.ReceiveFilesSubscriberImpl +import dev.arkbuilders.drop.domain.libwrapper.receive.request.DropReceiveFilesRequest +import dev.arkbuilders.drop.domain.libwrapper.send.DropSendFilesBubble +import dev.arkbuilders.drop.domain.libwrapper.send.DropSendFilesSubscriber +import dev.arkbuilders.drop.domain.libwrapper.send.DropSendFilesSubscriberImpl +import dev.arkbuilders.drop.domain.libwrapper.send.SendFilesSubscriberImpl +import dev.arkbuilders.drop.domain.libwrapper.send.request.DropSendFilesRequest + +class IOSDropApi : DropApi { + override fun createSendSubscriber(): DropSendFilesSubscriber { + crashlytics_log("IOSDropApi: createSendSubscriber") + return DropSendFilesSubscriberImpl(SendFilesSubscriberImpl()) + } + + override suspend fun sendFiles(request: DropSendFilesRequest): DropSendFilesBubble { + // Use the bridge wrapper to call the Objective-C bridge + crashlytics_log( + "IOSDropApi: sendFiles fileCount=${request.files.size}", + ) + return ArkDropBridgeWrapper.sendFiles(request) + } + + override fun createReceiveSubscriber(): DropReceiveFilesSubscriber { + crashlytics_log("IOSDropApi: createReceiveSubscriber") + return DropReceiveFilesSubscriberImpl(ReceiveFilesSubscriberImpl()) + } + + override suspend fun receiveFiles(request: DropReceiveFilesRequest): DropReceiveFilesBubble { + // Use the bridge wrapper to call the Objective-C bridge + crashlytics_log("IOSDropApi: receiveFiles") + return ArkDropBridgeWrapper.receiveFiles(request) + } +} + actual fun getDropApi(): DropApi { - throw NotImplementedError() + crashlytics_log("getDropApi: returning IOSDropApi instance") + return IOSDropApi() } diff --git a/shared/src/iosMain/kotlin/dev/arkbuilders/drop/domain/libwrapper/bridge/ArkDropBridgeWrapper.ios.kt b/shared/src/iosMain/kotlin/dev/arkbuilders/drop/domain/libwrapper/bridge/ArkDropBridgeWrapper.ios.kt new file mode 100644 index 0000000..88a1a62 --- /dev/null +++ b/shared/src/iosMain/kotlin/dev/arkbuilders/drop/domain/libwrapper/bridge/ArkDropBridgeWrapper.ios.kt @@ -0,0 +1,409 @@ +@file:OptIn(ExperimentalForeignApi::class) + +package dev.arkbuilders.drop.domain.libwrapper.bridge + +import dev.arkbuilders.drop.bridge.ArkDropBridge +import dev.arkbuilders.drop.bridge.ArkDropReceiveFilesBubbleProtocol +import dev.arkbuilders.drop.bridge.ArkDropReceiveFilesRequest +import dev.arkbuilders.drop.bridge.ArkDropReceiveFilesSubscriberProtocol +import dev.arkbuilders.drop.bridge.ArkDropReceiverConfig +import dev.arkbuilders.drop.bridge.ArkDropReceiverProfile +import dev.arkbuilders.drop.bridge.ArkDropSendFilesBubbleProtocol +import dev.arkbuilders.drop.bridge.ArkDropSendFilesRequest +import dev.arkbuilders.drop.bridge.ArkDropSendFilesSubscriberProtocol +import dev.arkbuilders.drop.bridge.ArkDropSenderConfig +import dev.arkbuilders.drop.bridge.ArkDropSenderFile +import dev.arkbuilders.drop.bridge.ArkDropSenderFileDataProtocol +import dev.arkbuilders.drop.bridge.ArkDropSenderProfile +import dev.arkbuilders.drop.bridge.crashlytics_log +import dev.arkbuilders.drop.bridge.crashlytics_recordError +import dev.arkbuilders.drop.domain.libwrapper.receive.DropReceiveFilesBubble +import dev.arkbuilders.drop.domain.libwrapper.receive.DropReceiveFilesSubscriber +import dev.arkbuilders.drop.domain.libwrapper.receive.DropReceiveFilesSubscriberImpl +import dev.arkbuilders.drop.domain.libwrapper.receive.request.DropReceiveFilesRequest +import dev.arkbuilders.drop.domain.libwrapper.send.DropSendFilesBubble +import dev.arkbuilders.drop.domain.libwrapper.send.DropSendFilesSubscriber +import dev.arkbuilders.drop.domain.libwrapper.send.request.DropSendFilesRequest +import dev.arkbuilders.drop.domain.libwrapper.send.request.DropSenderFileData +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.ObjCObjectVar +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.alloc +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.cinterop.reinterpret +import kotlinx.cinterop.usePinned +import kotlinx.cinterop.value +import platform.Foundation.NSData +import platform.Foundation.NSError +import platform.Foundation.NSNumber +import platform.Foundation.data +import platform.Foundation.dataWithBytes +import platform.Foundation.getBytes +import platform.Foundation.numberWithUnsignedChar +import platform.darwin.NSObject + +/** + * Wrapper for ArkDrop Objective-C bridge + * This bridges between Kotlin types and Objective-C bridge types + */ +object ArkDropBridgeWrapper { + suspend fun sendFiles(request: DropSendFilesRequest): DropSendFilesBubble { + crashlytics_log( + "ArkDropBridgeWrapper: sendFiles - " + + "fileCount: ${request.files.size}", + ) + crashlytics_log( + "ArkDropBridgeWrapper: sendFiles - " + + "config chunkSize: ${request.config?.chunkSize}, " + + "parallelStreams: ${request.config?.parallelStreams}", + ) + + val profile = + ArkDropSenderProfile().apply { + this.name = request.profile.name + this.avatarB64 = request.profile.avatarB64 + } + + val files = + request.files.map { file -> + val fileData = ArkDropSenderFileDataAdapter(file.data) + crashlytics_log( + "ArkDropBridgeWrapper: bridging file - " + + "dataLen: ${file.data.len()}", + ) + ArkDropSenderFile().apply { + this.name = file.name + this.data = fileData + } + } + + val config = + request.config?.let { c -> + ArkDropSenderConfig().apply { + this.chunkSize = c.chunkSize + this.parallelStreams = c.parallelStreams + } + } + + val bridgeRequest = + ArkDropSendFilesRequest().apply { + this.profile = profile + this.files = files + this.config = config + } + + return kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) { + memScoped { + val bubblePtr = + alloc>() + val errorPtr = alloc>() + + crashlytics_log( + "ArkDropBridgeWrapper: calling native ArkDropBridge.sendFilesWithRequest", + ) + ArkDropBridge.sendFilesWithRequest( + bridgeRequest, + bubble = bubblePtr.ptr, + error = errorPtr.ptr, + ) + + val error = errorPtr.value + if (error != null) { + val errorMsg = + "Failed to send files (native bridge error): " + + error.localizedDescription + crashlytics_recordError("ArkDropBridgeWrapper: $errorMsg", null) + throw Exception(errorMsg) + } + + val bubble = bubblePtr.value ?: throw Exception("Failed to create send bubble") + crashlytics_log("ArkDropBridgeWrapper: bridge returned send bubble successfully") + ArkDropSendFilesBubbleWrapper(bubble) + } + } + } + + suspend fun receiveFiles(request: DropReceiveFilesRequest): DropReceiveFilesBubble { + crashlytics_log( + "ArkDropBridgeWrapper: receiveFiles - " + + "chunkSize: ${request.config.chunkSize}, " + + "parallelStreams: ${request.config.parallelStreams}", + ) + + val profile = + ArkDropReceiverProfile().apply { + this.name = request.profile.name + this.avatarB64 = request.profile.avatarB64 + } + + val config = + ArkDropReceiverConfig().apply { + this.chunkSize = request.config.chunkSize + this.parallelStreams = request.config.parallelStreams + } + + val bridgeRequest = + ArkDropReceiveFilesRequest().apply { + this.ticket = request.ticket + this.confirmation = request.confirmation + this.profile = profile + this.config = config + } + + return kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) { + memScoped { + val bubblePtr = + alloc>() + val errorPtr = alloc>() + + crashlytics_log( + "ArkDropBridgeWrapper: calling native ArkDropBridge.receiveFilesWithRequest", + ) + ArkDropBridge.receiveFilesWithRequest( + bridgeRequest, + bubble = bubblePtr.ptr, + error = errorPtr.ptr, + ) + + val error = errorPtr.value + if (error != null) { + val errorMsg = + "Failed to receive files (native bridge error): " + + error.localizedDescription + crashlytics_recordError( + "ArkDropBridgeWrapper: " + + errorMsg, + null, + ) + throw Exception(errorMsg) + } + + val bubble = bubblePtr.value ?: throw Exception("Failed to create receive bubble") + crashlytics_log("ArkDropBridgeWrapper: bridge returned receive bubble successfully") + ArkDropReceiveFilesBubbleWrapper(bubble) + } + } + } +} + +/** + * Adapter that wraps DropSenderFileData to implement ArkDropSenderFileData protocol + * CRITICAL: Must catch ALL exceptions to prevent crossing into Rust + */ +private class ArkDropSenderFileDataAdapter( + private val data: DropSenderFileData, +) : NSObject(), ArkDropSenderFileDataProtocol { + override fun len(): ULong { + return try { + data.len() + } catch (e: Exception) { + println("⚠️ ArkDropSenderFileDataAdapter.len() error: $e") + 0u + } + } + + override fun read(): NSNumber? { + return try { + val byte = data.read() + byte?.let { NSNumber.numberWithUnsignedChar(it) } + } catch (e: Exception) { + println("⚠️ ArkDropSenderFileDataAdapter.read() error: $e") + null + } + } + + override fun readChunkWithSize(size: Int): NSData { + return try { + val bytes = data.readChunk(size) + bytes.usePinned { pinned -> + NSData.dataWithBytes(pinned.addressOf(0), length = bytes.size.toULong()) + } + } catch (e: Exception) { + println("⚠️ ArkDropSenderFileDataAdapter.readChunk() error: $e") + // Return empty NSData on error + NSData.data() + } + } +} + +/** + * Wrapper for ArkDropSendFilesBubble + */ +private class ArkDropSendFilesBubbleWrapper( + private val bubble: ArkDropSendFilesBubbleProtocol, +) : DropSendFilesBubble { + override suspend fun cancel() { + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) { + kotlinx.coroutines.suspendCancellableCoroutine { cont -> + bubble.cancelWithCompletion { error -> + if (error != null) { + cont.resumeWith(Result.failure(Exception(error.localizedDescription))) + } else { + cont.resumeWith(Result.success(Unit)) + } + } + cont.invokeOnCancellation { + // Handle cancellation if needed + } + } + } + } + + override fun getConfirmation(): UByte = bubble.getConfirmation() + + override fun getCreatedAt(): String = bubble.getCreatedAt() + + override fun getTicket(): String = bubble.getTicket() + + override fun isConnected(): Boolean = bubble.isConnected() + + override fun isFinished(): Boolean = bubble.isFinished() + + override fun subscribe(subscriber: DropSendFilesSubscriber) { + val bridgeSubscriber = ArkDropSendFilesSubscriberAdapter(subscriber) + bubble.subscribeWithSubscriber(bridgeSubscriber) + } + + override fun unsubscribe(subscriber: DropSendFilesSubscriber) { + val bridgeSubscriber = ArkDropSendFilesSubscriberAdapter(subscriber) + bubble.unsubscribeWithSubscriber(bridgeSubscriber) + } +} + +/** + * Wrapper for ArkDropReceiveFilesBubble + */ +private class ArkDropReceiveFilesBubbleWrapper( + private val bubble: ArkDropReceiveFilesBubbleProtocol, +) : DropReceiveFilesBubble { + override fun cancel() { + bubble.cancel() + } + + override fun isCancelled(): Boolean = bubble.isCancelled() + + override fun isFinished(): Boolean = bubble.isFinished() + + override fun start() { + memScoped { + val errorPtr = alloc>() + bubble.startWithError(errorPtr.ptr) + val error = errorPtr.value + if (error != null) { + throw Exception("Failed to start receive: ${error.localizedDescription}") + } + } + } + + override fun subscribe(subscriber: DropReceiveFilesSubscriber) { + val bridgeSubscriber = ArkDropReceiveFilesSubscriberAdapter(subscriber) + bubble.subscribeWithSubscriber(bridgeSubscriber) + } + + override fun unsubscribe(subscriber: DropReceiveFilesSubscriber) { + val bridgeSubscriber = ArkDropReceiveFilesSubscriberAdapter(subscriber) + bubble.unsubscribeWithSubscriber(bridgeSubscriber) + } +} + +/** + * Adapter that wraps DropSendFilesSubscriber to implement ArkDropSendFilesSubscriber protocol + */ +private class ArkDropSendFilesSubscriberAdapter( + private val subscriber: DropSendFilesSubscriber, +) : NSObject(), ArkDropSendFilesSubscriberProtocol { + private val native = + (subscriber as? dev.arkbuilders.drop.domain.libwrapper.send.DropSendFilesSubscriberImpl) + ?.native + ?: throw IllegalArgumentException("Invalid subscriber type") + + override fun getId(): String = native.getId() + + override fun logWithMessage(message: String) { + native.log(message) + } + + override fun notifySendingWithName( + name: String, + sent: ULong, + remaining: ULong, + ) { + native.updateSendingProgress(name, sent, remaining) + } + + override fun notifyConnectingWithReceiverName( + receiverName: String, + receiverAvatarB64: String?, + ) { + native.updateConnectionStatus(receiverName, receiverAvatarB64) + } +} + +/** + * Adapter that wraps DropReceiveFilesSubscriber to implement ArkDropReceiveFilesSubscriber protocol + */ +private class ArkDropReceiveFilesSubscriberAdapter( + private val subscriber: DropReceiveFilesSubscriber, +) : NSObject(), ArkDropReceiveFilesSubscriberProtocol { + private val native = + (subscriber as? DropReceiveFilesSubscriberImpl) + ?.native + ?: throw IllegalArgumentException("Invalid subscriber type") + + override fun getId(): String = native.getId() + + override fun logWithMessage(message: String) { + native.log(message) + } + + override fun notifyReceivingWithFileId( + fileId: String, + data: NSData, + ) { + try { + val length = data.length.toInt() + val bytes = ByteArray(length) + bytes.usePinned { pinned -> + data.getBytes(pinned.addressOf(0).reinterpret(), length.toULong()) + } + native.appendReceivedData(fileId, bytes) + } catch (e: Exception) { + println("⚠️ ArkDropReceiveFilesSubscriberAdapter.notifyReceiving error: $e") + } + } + + override fun notifyConnectingWithSenderName( + senderName: String, + senderAvatarB64: String?, + files: List<*>, + ) { + try { + val fileInfos = + files.mapNotNull { fileDict -> + val dict = fileDict as? Map<*, *> ?: return@mapNotNull null + val id = dict["id"] as? String ?: return@mapNotNull null + val name = dict["name"] as? String ?: return@mapNotNull null + val len = + (dict["len"] as? Number)?.toLong()?.toULong() ?: return@mapNotNull null + + dev.arkbuilders.drop.domain.libwrapper.receive.ReceiveFileInfo( + id = id, + name = name, + size = len, + ) + } + + val currentProgress = native.progress.value + native.progressMutable.value = + currentProgress.copy( + isConnected = true, + senderName = senderName, + senderAvatar = senderAvatarB64, + files = fileInfos, + ) + } catch (e: Exception) { + println("⚠️ ArkDropReceiveFilesSubscriberAdapter.notifyConnecting error: $e") + } + } +} diff --git a/shared/src/iosMain/kotlin/dev/arkbuilders/drop/domain/libwrapper/receive/DropReceiveFilesBubbleImpl.ios.kt b/shared/src/iosMain/kotlin/dev/arkbuilders/drop/domain/libwrapper/receive/DropReceiveFilesBubbleImpl.ios.kt new file mode 100644 index 0000000..16ad1ce --- /dev/null +++ b/shared/src/iosMain/kotlin/dev/arkbuilders/drop/domain/libwrapper/receive/DropReceiveFilesBubbleImpl.ios.kt @@ -0,0 +1,144 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + +package dev.arkbuilders.drop.domain.libwrapper.receive + +import dev.arkbuilders.drop.bridge.ArkDropReceiveFilesBubbleProtocol +import dev.arkbuilders.drop.bridge.ArkDropReceiveFilesSubscriberProtocol +import dev.arkbuilders.drop.bridge.crashlytics_log +import dev.arkbuilders.drop.bridge.crashlytics_recordError +import kotlinx.cinterop.ObjCObjectVar +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.alloc +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.cinterop.usePinned +import kotlinx.cinterop.value +import platform.Foundation.NSData +import platform.Foundation.NSError +import platform.Foundation.getBytes +import platform.darwin.NSObject + +class DropReceiveFilesBubbleImpl( + private val bubble: ArkDropReceiveFilesBubbleProtocol, +) : DropReceiveFilesBubble { + override fun cancel() { + crashlytics_log("DropReceiveFilesBubble: cancel called") + bubble.cancel() + } + + override fun isCancelled(): Boolean { + val cancelled = bubble.isCancelled() + crashlytics_log("DropReceiveFilesBubble: isCancelled=$cancelled") + return cancelled + } + + override fun isFinished(): Boolean { + val finished = bubble.isFinished() + crashlytics_log("DropReceiveFilesBubble: isFinished=$finished") + return finished + } + + override fun start() { + crashlytics_log("DropReceiveFilesBubble: starting bubble") + memScoped { + val errorPtr = alloc>() + bubble.startWithError(errorPtr.ptr) + val error = errorPtr.value + if (error != null) { + crashlytics_recordError( + "DropReceiveFilesBubble: start failed " + + "error=${error.localizedDescription}", + null, + ) + throw Exception("Failed to start: ${error.localizedDescription}") + } + } + crashlytics_log("DropReceiveFilesBubble: started successfully") + } + + override fun subscribe(subscriber: DropReceiveFilesSubscriber) { + crashlytics_log("DropReceiveFilesBubble: subscribing subscriber") + val adapter = ArkDropReceiveFilesSubscriberAdapter(subscriber) + bubble.subscribeWithSubscriber(adapter) + crashlytics_log("DropReceiveFilesBubble: subscribed") + } + + override fun unsubscribe(subscriber: DropReceiveFilesSubscriber) { + crashlytics_log("DropReceiveFilesBubble: unsubscribing subscriber") + val adapter = ArkDropReceiveFilesSubscriberAdapter(subscriber) + bubble.unsubscribeWithSubscriber(adapter) + crashlytics_log("DropReceiveFilesBubble: unsubscribed") + } +} + +private class ArkDropReceiveFilesSubscriberAdapter( + private val subscriber: DropReceiveFilesSubscriber, +) : NSObject(), ArkDropReceiveFilesSubscriberProtocol { + private val native = + (subscriber as? DropReceiveFilesSubscriberImpl) + ?.native as? ReceiveFilesSubscriberImpl + ?: throw IllegalArgumentException("Invalid subscriber type") + + override fun getId(): String = native.getId() + + override fun logWithMessage(message: String) { + native.log(message) + } + + override fun notifyReceivingWithFileId( + fileId: String, + data: NSData, + ) { + val length = data.length.toInt() + crashlytics_log( + "ArkDropReceiveFilesSubscriberAdapter: receiving data bytes=$length", + ) + val bytes = ByteArray(length) + bytes.usePinned { pinned -> + data.getBytes(pinned.addressOf(0), length = length.toULong()) + } + native.appendReceivedData(fileId, bytes) + } + + override fun notifyConnectingWithSenderName( + senderName: String, + senderAvatarB64: String?, + files: List<*>, + ) { + crashlytics_log( + "ArkDropReceiveFilesSubscriberAdapter: " + + "connected to sender fileCount=${files.size}", + ) + + val fileInfos = + files.mapNotNull { fileDict -> + val dict = fileDict as? Map<*, *> ?: return@mapNotNull null + val id = dict["id"] as? String ?: return@mapNotNull null + val name = dict["name"] as? String ?: return@mapNotNull null + val len = (dict["len"] as? Number)?.toLong()?.toULong() ?: return@mapNotNull null + + ReceiveFileInfo( + id = id, + name = name, + size = len, + ) + } + + crashlytics_log( + "ArkDropReceiveFilesSubscriberAdapter: parsed fileInfos count=${fileInfos.size}", + ) + val totalBytes = fileInfos.sumOf { it.size.toLong() } + crashlytics_log( + "ArkDropReceiveFilesSubscriberAdapter: fileInfos totalBytes=$totalBytes", + ) + + val currentProgress = native.progress.value + native.progressMutable.value = + currentProgress.copy( + isConnected = true, + senderName = senderName, + senderAvatar = senderAvatarB64, + files = fileInfos, + ) + } +} diff --git a/shared/src/iosMain/kotlin/dev/arkbuilders/drop/domain/libwrapper/receive/DropReceiveFilesSubscriberImpl.ios.kt b/shared/src/iosMain/kotlin/dev/arkbuilders/drop/domain/libwrapper/receive/DropReceiveFilesSubscriberImpl.ios.kt new file mode 100644 index 0000000..a955b69 --- /dev/null +++ b/shared/src/iosMain/kotlin/dev/arkbuilders/drop/domain/libwrapper/receive/DropReceiveFilesSubscriberImpl.ios.kt @@ -0,0 +1,152 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + +package dev.arkbuilders.drop.domain.libwrapper.receive + +import dev.arkbuilders.drop.bridge.crashlytics_log +import dev.arkbuilders.drop.bridge.crashlytics_recordError +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.usePinned +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import platform.Foundation.NSMutableData +import platform.Foundation.NSUUID +import platform.Foundation.appendBytes +import platform.Foundation.dataWithCapacity +import platform.Foundation.getBytes + +class DropReceiveFilesSubscriberImpl( + val native: ReceiveFilesSubscriberImpl, +) : DropReceiveFilesSubscriber { + override val progress: StateFlow = native.progress + + override fun getCompleteFiles() = native.getCompleteFiles() +} + +class ReceiveFilesSubscriberImpl { + companion object { + private const val TAG = "ReceiveFilesSubscriber" + } + + private val id = NSUUID().UUIDString + + // Thread-safe storage for received data using NSMutableData for efficient appending + private val receivedDataMap = mutableMapOf() + + internal val progressMutable = MutableStateFlow(DropReceivingProgress()) + val progress: StateFlow = progressMutable.asStateFlow() + + fun getId(): String = id + + fun log(message: String) { + // On iOS, we can use NSLog or a logging framework + crashlytics_log("ReceiveFilesSubscriber: $message") + } + + fun reset() { + // Clear all data streams + crashlytics_log("ReceiveFilesSubscriber: reset called") + receivedDataMap.clear() + progressMutable.value = DropReceivingProgress() + crashlytics_log("ReceiveFilesSubscriber: reset completed") + } + + /** + * Get files that have been completely received + */ + fun getCompleteFiles(): List> { + val currentProgress = progressMutable.value + crashlytics_log( + "ReceiveFilesSubscriber: getCompleteFiles totalFiles=${currentProgress.files.size}", + ) + + val result = + currentProgress.files.mapNotNull { fileInfo -> + val progressInfo = currentProgress.fileProgress[fileInfo.id] + if (progressInfo?.isComplete == true) { + val data = receivedDataMap[fileInfo.id] + if (data != null) { + val length = data.length.toInt() + crashlytics_log( + "ReceiveFilesSubscriber: complete " + + "bytes=$length", + ) + val bytes = ByteArray(length) + bytes.usePinned { pinned -> + data.getBytes( + pinned.addressOf(0), + length = length.toULong(), + ) + } + Pair(fileInfo, bytes) + } else { + crashlytics_recordError( + "ReceiveFilesSubscriber: " + + "complete file missing data", + null, + ) + null + } + } else { + null + } + } + + crashlytics_log("ReceiveFilesSubscriber: getCompleteFiles returning ${result.size} files") + return result + } + + /** + * Helper method to append received data directly (for bridge use) + */ + fun appendReceivedData( + fileId: String, + data: ByteArray, + ) { + crashlytics_log( + "ReceiveFilesSubscriber: appendReceivedData chunkSize=${data.size}", + ) + + val existingData = + receivedDataMap.getOrPut(fileId) { + NSMutableData.dataWithCapacity(0u) as NSMutableData + } + data.usePinned { pinned -> + existingData.appendBytes(pinned.addressOf(0), length = data.size.toULong()) + } + + // Update progress + val currentProgress = progressMutable.value + val fileInfo = currentProgress.files.find { it.id == fileId } + + if (fileInfo != null) { + val receivedBytes = existingData.length.toLong() + val isComplete = receivedBytes.toULong() >= fileInfo.size + val totalSize = fileInfo.size + + crashlytics_log( + "ReceiveFilesSubscriber: file progress " + + "received=$receivedBytes " + + "total=$totalSize " + + "complete=$isComplete", + ) + + val updatedFileProgress = currentProgress.fileProgress.toMutableMap() + updatedFileProgress[fileId] = + FileProgressInfo( + receivedBytes = receivedBytes, + isComplete = isComplete, + ) + + progressMutable.value = + currentProgress.copy( + fileProgress = updatedFileProgress.toMap(), + ) + } else { + crashlytics_log( + "ReceiveFilesSubscriber: " + + "file not in expected file list, buffering data size=${data.size}", + ) + } + } +} diff --git a/shared/src/iosMain/kotlin/dev/arkbuilders/drop/domain/libwrapper/send/DropSendFilesBubbleImpl.ios.kt b/shared/src/iosMain/kotlin/dev/arkbuilders/drop/domain/libwrapper/send/DropSendFilesBubbleImpl.ios.kt new file mode 100644 index 0000000..c53428b --- /dev/null +++ b/shared/src/iosMain/kotlin/dev/arkbuilders/drop/domain/libwrapper/send/DropSendFilesBubbleImpl.ios.kt @@ -0,0 +1,125 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + +package dev.arkbuilders.drop.domain.libwrapper.send + +import dev.arkbuilders.drop.bridge.ArkDropSendFilesBubbleProtocol +import dev.arkbuilders.drop.bridge.ArkDropSendFilesSubscriberProtocol +import dev.arkbuilders.drop.bridge.crashlytics_log +import dev.arkbuilders.drop.bridge.crashlytics_recordError +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import platform.darwin.NSObject +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +class DropSendFilesBubbleImpl( + private val bubble: ArkDropSendFilesBubbleProtocol, +) : DropSendFilesBubble { + override suspend fun cancel() { + crashlytics_log("DropSendFilesBubble: cancel called") + withContext(Dispatchers.Main) { + suspendCancellableCoroutine { cont -> + bubble.cancelWithCompletion { error -> + if (error != null) { + crashlytics_recordError( + "DropSendFilesBubble: " + + "cancel failed error=${error.localizedDescription}", + null, + ) + cont.resumeWithException(Exception(error.localizedDescription)) + } else { + crashlytics_log("DropSendFilesBubble: cancel completed") + cont.resume(Unit) + } + } + cont.invokeOnCancellation { + crashlytics_log("DropSendFilesBubble: cancel was cancelled") + } + } + } + } + + override fun getConfirmation(): UByte { + val confirmation = bubble.getConfirmation() + crashlytics_log("DropSendFilesBubble: getConfirmation") + return confirmation + } + + override fun getCreatedAt(): String { + val createdAt = bubble.getCreatedAt() + crashlytics_log("DropSendFilesBubble: getCreatedAt=$createdAt") + return createdAt + } + + override fun getTicket(): String { + val ticket = bubble.getTicket() + crashlytics_log("DropSendFilesBubble: getTicket") + return ticket + } + + override fun isConnected(): Boolean { + val connected = bubble.isConnected() + crashlytics_log("DropSendFilesBubble: isConnected=$connected") + return connected + } + + override fun isFinished(): Boolean { + val finished = bubble.isFinished() + crashlytics_log("DropSendFilesBubble: isFinished=$finished") + return finished + } + + override fun subscribe(subscriber: DropSendFilesSubscriber) { + crashlytics_log("DropSendFilesBubble: subscribing subscriber") + val adapter = ArkDropSendFilesSubscriberAdapter(subscriber) + bubble.subscribeWithSubscriber(adapter) + crashlytics_log("DropSendFilesBubble: subscribed") + } + + override fun unsubscribe(subscriber: DropSendFilesSubscriber) { + crashlytics_log("DropSendFilesBubble: unsubscribing subscriber") + val adapter = ArkDropSendFilesSubscriberAdapter(subscriber) + bubble.unsubscribeWithSubscriber(adapter) + crashlytics_log("DropSendFilesBubble: unsubscribed") + } +} + +/** + * Adapter that wraps DropSendFilesSubscriber to implement ArkDropSendFilesSubscriber protocol + */ +private class ArkDropSendFilesSubscriberAdapter( + private val subscriber: DropSendFilesSubscriber, +) : NSObject(), ArkDropSendFilesSubscriberProtocol { + private val native = + (subscriber as? DropSendFilesSubscriberImpl) + ?.native as? SendFilesSubscriberImpl + ?: throw IllegalArgumentException("Invalid subscriber type") + + override fun getId(): String = native.getId() + + override fun logWithMessage(message: String) { + native.log(message) + } + + override fun notifySendingWithName( + name: String, + sent: ULong, + remaining: ULong, + ) { + crashlytics_log( + "ArkDropSendFilesSubscriberAdapter: sending progress " + + "sent=$sent " + + "remaining=$remaining", + ) + native.updateSendingProgress(name, sent, remaining) + } + + override fun notifyConnectingWithReceiverName( + receiverName: String, + receiverAvatarB64: String?, + ) { + crashlytics_log("ArkDropSendFilesSubscriberAdapter: connecting to receiver") + native.updateConnectionStatus(receiverName, receiverAvatarB64) + } +} diff --git a/shared/src/iosMain/kotlin/dev/arkbuilders/drop/domain/libwrapper/send/SenderFileDataImpl.ios.kt b/shared/src/iosMain/kotlin/dev/arkbuilders/drop/domain/libwrapper/send/SenderFileDataImpl.ios.kt new file mode 100644 index 0000000..0f161a8 --- /dev/null +++ b/shared/src/iosMain/kotlin/dev/arkbuilders/drop/domain/libwrapper/send/SenderFileDataImpl.ios.kt @@ -0,0 +1,160 @@ +@file:OptIn(ExperimentalForeignApi::class) + +package dev.arkbuilders.drop.domain.libwrapper.send + +import dev.arkbuilders.drop.bridge.crashlytics_log +import dev.arkbuilders.drop.bridge.crashlytics_recordError +import dev.arkbuilders.drop.domain.libwrapper.send.request.DropSenderFileData +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.reinterpret +import kotlinx.cinterop.usePinned +import platform.Foundation.NSInputStream +import platform.Foundation.NSNumber +import platform.Foundation.NSURL +import platform.Foundation.NSURLFileSizeKey +import platform.Foundation.inputStreamWithURL + +class SenderFileDataImpl( + private val uri: String, +) : DropSenderFileData { + companion object { + private const val TAG = "SenderFileDataImpl" + } + + private var inputStream: NSInputStream? = null + private var totalLength: ULong = 0UL + private var isInitialized = false + + private fun initialize() { + if (isInitialized) return + + println("SenderFileDataImpl: Initializing file") + crashlytics_log("SenderFileDataImpl: initializing file") + + try { + // Try as file path first, then as URL string + val url = + NSURL.fileURLWithPath(uri) + ?: NSURL.URLWithString(uri) + ?: run { + println("SenderFileDataImpl: Failed to create URL") + crashlytics_recordError("SenderFileDataImpl: failed to create URL", null) + return + } + + println("SenderFileDataImpl: Created URL") + + // Get file size + val resourceValues = url.resourceValuesForKeys(listOf(NSURLFileSizeKey), null) + resourceValues?.get(NSURLFileSizeKey)?.let { + totalLength = ((it as? NSNumber)?.longValue ?: 0L).toULong() + println("SenderFileDataImpl: File size: $totalLength bytes") + crashlytics_log("SenderFileDataImpl: file size=$totalLength bytes") + } ?: println("SenderFileDataImpl: Could not get file size").also { + crashlytics_log("SenderFileDataImpl: could not get file size") + } + + // Open input stream + inputStream = NSInputStream.inputStreamWithURL(url) + inputStream?.open() + + val status = inputStream?.streamStatus + println("SenderFileDataImpl: Stream status: $status") + + if (inputStream?.streamError != null) { + val errorDesc = inputStream?.streamError?.localizedDescription ?: "unknown" + println("SenderFileDataImpl: Stream error") + crashlytics_recordError( + "SenderFileDataImpl: stream error - $errorDesc", + null, + ) + return + } + + isInitialized = true + println("SenderFileDataImpl: Successfully initialized") + crashlytics_log("SenderFileDataImpl: successfully initialized") + } catch (e: Exception) { + println("SenderFileDataImpl: Failed to initialize file") + crashlytics_recordError( + "SenderFileDataImpl: failed to initialize", + e.message, + ) + } + } + + override fun len(): ULong { + initialize() + crashlytics_log("SenderFileDataImpl: len() returning=$totalLength") + return totalLength + } + + override fun read(): UByte? { + initialize() + if (!isInitialized) { + println("SenderFileDataImpl.read() - not initialized") + crashlytics_recordError("SenderFileDataImpl.read() - not initialized", null) + return null + } + return try { + val buffer = UByteArray(1) + val bytesRead = + buffer.usePinned { pinned -> + inputStream?.read( + pinned.addressOf(0).reinterpret(), + maxLength = 1u, + ) ?: 0L + } + if (bytesRead == 0L) { + inputStream?.close() + crashlytics_log("SenderFileDataImpl: read() reached end of file, stream closed") + null + } else { + buffer[0] + } + } catch (e: Exception) { + println("SenderFileDataImpl.read() error") + crashlytics_recordError("SenderFileDataImpl.read() error", e.message) + null + } + } + + override fun readChunk(size: Int): ByteArray { + initialize() + if (!isInitialized) { + println("SenderFileDataImpl.readChunk() - not initialized") + crashlytics_recordError("SenderFileDataImpl.readChunk() - not initialized", null) + return ByteArray(0) + } + return try { + val buffer = UByteArray(size) + val bytesRead = + buffer.usePinned { pinned -> + inputStream?.read( + pinned.addressOf(0).reinterpret(), + maxLength = size.toULong(), + ) ?: 0L + } + if (bytesRead == 0L) { + inputStream?.close() + crashlytics_log("SenderFileDataImpl: readChunk() reached end of file") + ByteArray(0) + } else { + val result = buffer.asByteArray().copyOf(bytesRead.toInt()) + crashlytics_log( + "SenderFileDataImpl: readChunk() - requested: $size, " + + "read: $bytesRead bytes", + ) + result + } + } catch (e: Exception) { + println("SenderFileDataImpl.readChunk() error") + crashlytics_recordError( + "SenderFileDataImpl.readChunk() error size=$size", + e.message, + ) + ByteArray(0) + } + } +} diff --git a/shared/src/iosMain/kotlin/dev/arkbuilders/drop/domain/libwrapper/send/SenderFilesSubscriberImpl.ios.kt b/shared/src/iosMain/kotlin/dev/arkbuilders/drop/domain/libwrapper/send/SenderFilesSubscriberImpl.ios.kt new file mode 100644 index 0000000..5021140 --- /dev/null +++ b/shared/src/iosMain/kotlin/dev/arkbuilders/drop/domain/libwrapper/send/SenderFilesSubscriberImpl.ios.kt @@ -0,0 +1,77 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + +package dev.arkbuilders.drop.domain.libwrapper.send + +import dev.arkbuilders.drop.bridge.crashlytics_log +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import platform.Foundation.NSUUID + +class DropSendFilesSubscriberImpl(val native: SendFilesSubscriberImpl) : DropSendFilesSubscriber { + override val progress: StateFlow = native.progress +} + +class SendFilesSubscriberImpl { + companion object { + private const val TAG = "SendFilesSubscriber" + } + + private val id = NSUUID().UUIDString + + private val _progress = MutableStateFlow(DropSendingProgress()) + val progress: StateFlow = _progress.asStateFlow() + + fun getId(): String = id + + fun log(message: String) { + // On iOS, we can use NSLog or a logging framework + crashlytics_log("SendFilesSubscriber: $message") + } + + // Note: These methods are called via the adapter from the bridge + // The original event-based methods are no longer used directly + + fun reset() { + crashlytics_log("SendFilesSubscriber: reset called") + _progress.value = DropSendingProgress() + crashlytics_log("SendFilesSubscriber: reset completed") + } + + /** + * Helper method to update sending progress directly (for bridge use) + */ + fun updateSendingProgress( + fileName: String, + sent: ULong, + remaining: ULong, + ) { + crashlytics_log( + "SendFilesSubscriber: updateSendingProgress " + + "sent=$sent " + + "remaining=$remaining", + ) + _progress.value = + _progress.value.copy( + fileName = fileName, + sent = sent, + remaining = remaining, + ) + } + + /** + * Helper method to update connection status (for bridge use) + */ + fun updateConnectionStatus( + receiverName: String, + receiverAvatar: String?, + ) { + crashlytics_log("SendFilesSubscriber: updateConnectionStatus receiver connected") + _progress.value = + _progress.value.copy( + isConnected = true, + receiverName = receiverName, + receiverAvatar = receiverAvatar, + ) + } +} diff --git a/shared/src/iosMain/kotlin/dev/arkbuilders/drop/instrumentation/AnalyticsReporter.ios.kt b/shared/src/iosMain/kotlin/dev/arkbuilders/drop/instrumentation/AnalyticsReporter.ios.kt new file mode 100644 index 0000000..9a70f1b --- /dev/null +++ b/shared/src/iosMain/kotlin/dev/arkbuilders/drop/instrumentation/AnalyticsReporter.ios.kt @@ -0,0 +1,34 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + +package dev.arkbuilders.drop.instrumentation + +import dev.arkbuilders.drop.bridge.analytics_logEvent +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject + +actual class AnalyticsReporter actual constructor() { + actual fun logEvent( + name: String, + params: Map, + ) { + val jsonParams = + buildJsonObject { + params.forEach { (key, value) -> + put(key, value.toJsonPrimitive()) + } + } + analytics_logEvent(name, jsonParams.toString()) + } + + private fun Any.toJsonPrimitive(): JsonPrimitive = + when (this) { + is Boolean -> JsonPrimitive(toString()) + is Byte -> JsonPrimitive(this) + is Short -> JsonPrimitive(this) + is Int -> JsonPrimitive(this) + is Long -> JsonPrimitive(this) + is Float -> JsonPrimitive(this.toDouble()) + is Double -> JsonPrimitive(this) + else -> JsonPrimitive(toString()) + } +} diff --git a/shared/src/iosMain/kotlin/dev/arkbuilders/drop/instrumentation/FirebaseReporter.ios.kt b/shared/src/iosMain/kotlin/dev/arkbuilders/drop/instrumentation/FirebaseReporter.ios.kt new file mode 100644 index 0000000..858a61c --- /dev/null +++ b/shared/src/iosMain/kotlin/dev/arkbuilders/drop/instrumentation/FirebaseReporter.ios.kt @@ -0,0 +1,28 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + +package dev.arkbuilders.drop.instrumentation + +import dev.arkbuilders.drop.bridge.crashlytics_log +import dev.arkbuilders.drop.bridge.crashlytics_recordError +import dev.arkbuilders.drop.bridge.crashlytics_setCustomKey + +actual class FirebaseReporter { + actual fun recordError( + message: String, + throwable: Throwable?, + ) { + val stackTrace = throwable?.stackTraceToString() + crashlytics_recordError(message, stackTrace) + } + + actual fun log(message: String) { + crashlytics_log(message) + } + + actual fun setCustomKey( + key: String, + value: String, + ) { + crashlytics_setCustomKey(key, value) + } +} diff --git a/shared/src/nativeInterop/cinterop/AnalyticsBridge.def b/shared/src/nativeInterop/cinterop/AnalyticsBridge.def new file mode 100644 index 0000000..794826e --- /dev/null +++ b/shared/src/nativeInterop/cinterop/AnalyticsBridge.def @@ -0,0 +1,6 @@ +language = Objective-C +headers = AnalyticsBridge.h +headerFilter = AnalyticsBridge.h +package = dev.arkbuilders.drop.bridge +compilerOpts = -framework Foundation +linkerOpts = -framework Foundation diff --git a/shared/src/nativeInterop/cinterop/ArkDropBridge.def b/shared/src/nativeInterop/cinterop/ArkDropBridge.def new file mode 100644 index 0000000..a279370 --- /dev/null +++ b/shared/src/nativeInterop/cinterop/ArkDropBridge.def @@ -0,0 +1,6 @@ +language = Objective-C +headers = ArkDropBridge.h +headerFilter = ArkDropBridge.h +package = dev.arkbuilders.drop.bridge +compilerOpts = -framework Foundation +linkerOpts = -framework Foundation diff --git a/shared/src/nativeInterop/cinterop/CrashlyticsBridge.def b/shared/src/nativeInterop/cinterop/CrashlyticsBridge.def new file mode 100644 index 0000000..883b5dd --- /dev/null +++ b/shared/src/nativeInterop/cinterop/CrashlyticsBridge.def @@ -0,0 +1,6 @@ +language = Objective-C +headers = CrashlyticsBridge.h +headerFilter = CrashlyticsBridge.h +package = dev.arkbuilders.drop.bridge +compilerOpts = -framework Foundation +linkerOpts = -framework Foundation