diff --git a/.github/workflows/release-ios.yml b/.github/workflows/release-ios.yml new file mode 100644 index 0000000..2f8dd6d --- /dev/null +++ b/.github/workflows/release-ios.yml @@ -0,0 +1,375 @@ +name: Release iOS App + +# Trigger: push tags or manual dispatch +on: + push: + tags: + - 'v*' + branches: + # TEMPORARY: Remove after testing + - 'feature/kmp-ios-impl' + - 'fix/kmp-ios-impl' + workflow_dispatch: + +jobs: + build: + runs-on: macos-26 # macOS 26 Tahoe with Xcode 26.2 (required for App Store from Apr 28, 2026 - ITMS-90725) + environment: Testflight + + 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: + # 1️⃣ Checkout code + - name: Checkout repository + uses: actions/checkout@v4 + + # 🔍 Diagnostic: Check all secrets availability + - name: Validate Secrets + run: | + echo "🔍 Checking which secrets are available..." + echo "" + + # Check each required secret + MISSING_SECRETS=() + + if [ -z "${{ secrets.IOS_P12_BASE64 }}" ]; then + echo "❌ IOS_P12_BASE64 - MISSING or EMPTY" + MISSING_SECRETS+=("IOS_P12_BASE64") + else + echo "✅ IOS_P12_BASE64 - Available (${#IOS_P12_BASE64} chars)" + fi + + if [ -z "${{ secrets.IOS_P12_PASSWORD }}" ]; then + echo "❌ IOS_P12_PASSWORD - MISSING or EMPTY" + MISSING_SECRETS+=("IOS_P12_PASSWORD") + else + echo "✅ IOS_P12_PASSWORD - Available" + fi + + if [ -z "${{ secrets.IOS_PROFILE_BASE64 }}" ]; then + echo "❌ IOS_PROFILE_BASE64 - MISSING or EMPTY" + MISSING_SECRETS+=("IOS_PROFILE_BASE64") + else + echo "✅ IOS_PROFILE_BASE64 - Available (${#IOS_PROFILE_BASE64} chars)" + fi + + if [ -z "${{ secrets.ASC_API_KEY_BASE64 }}" ]; then + echo "❌ ASC_API_KEY_BASE64 - MISSING or EMPTY" + MISSING_SECRETS+=("ASC_API_KEY_BASE64") + else + echo "✅ ASC_API_KEY_BASE64 - Available (${#ASC_API_KEY_BASE64} chars)" + fi + + if [ -z "${{ secrets.ASC_KEY_ID }}" ]; then + echo "❌ ASC_KEY_ID - MISSING or EMPTY" + MISSING_SECRETS+=("ASC_KEY_ID") + else + echo "✅ ASC_KEY_ID - Available" + fi + + if [ -z "${{ secrets.ASC_ISSUER_ID }}" ]; then + echo "❌ ASC_ISSUER_ID - MISSING or EMPTY" + MISSING_SECRETS+=("ASC_ISSUER_ID") + else + echo "✅ ASC_ISSUER_ID - Available" + fi + + echo "" + echo "📊 Summary: ${#MISSING_SECRETS[@]} secrets missing" + + if [ ${#MISSING_SECRETS[@]} -gt 0 ]; then + echo "" + echo "⚠️ Missing secrets need to be added to:" + echo " Settings → Environments → Testflight → Environment secrets" + echo "" + echo "Current repository: ${{ github.repository }}" + echo "Current environment: Testflight" + exit 1 + fi + + echo "" + echo "✅ All required secrets are available!" + env: + IOS_P12_BASE64: ${{ secrets.IOS_P12_BASE64 }} + IOS_PROFILE_BASE64: ${{ secrets.IOS_PROFILE_BASE64 }} + ASC_API_KEY_BASE64: ${{ secrets.ASC_API_KEY_BASE64 }} + + # 2️⃣ Setup Ruby and Fastlane + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.2" + bundler-cache: true + + - name: Install gems + run: bundle install + + # 3️⃣ Build Kotlin framework before Xcode (required for KMP - Xcode expects it at XCFrameworks/debug) + - 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/ + + # 3b Ensure XCFramework has Info.plist (Kotlin may not create it; Xcode requires it) + - name: Ensure XCFramework Info.plist + run: | + XCF_DIR="shared/build/XCFrameworks/debug/shared.xcframework" + if [ ! -f "$XCF_DIR/Info.plist" ]; then + echo "Creating Info.plist for XCFramework..." + cp shared/build/XCFrameworks/release/shared.xcframework/Info.plist "$XCF_DIR/" 2>/dev/null || \ + cp shared/XCFramework-Info.plist "$XCF_DIR/Info.plist" + echo "✅ Info.plist ready" + else + echo "✅ Info.plist already exists" + fi + + # 4️⃣ Setup Xcode 26.2 (macos-26 has it pre-installed) + - name: Select Xcode version + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '26.2' + + # 4️⃣ Decode certificate (Fastlane will import it) + - name: Decode iOS certificate + run: | + # Check if the secret exists and is not empty + if [ -z "$IOS_P12_BASE64" ]; then + echo "❌ ERROR: IOS_P12_BASE64 secret is missing or empty!" + exit 1 + fi + + # Decode certificate + echo "$IOS_P12_BASE64" | base64 --decode > cert.p12 + + # Verify the .p12 file was created and has content + FILE_SIZE=$(stat -f%z cert.p12) + echo "✅ Certificate file size: $FILE_SIZE bytes" + + if [ "$FILE_SIZE" -lt 100 ]; then + echo "❌ ERROR: Certificate file is too small, likely corrupted" + exit 1 + fi + env: + IOS_P12_BASE64: ${{ secrets.IOS_P12_BASE64 }} + + # 6️⃣ Validate iOS certificate contains private key + - name: Validate iOS certificate contains private key + run: | + set +e # Don't exit on openssl failure - we need to show the error + echo "🔍 Validating p12 certificate contents..." + echo "" + + # Check if p12 file exists + if [ ! -f cert.p12 ]; then + echo "❌ ERROR: cert.p12 file not found!" + exit 1 + fi + + # Test 1: Check for PRIVATE KEY in p12 structure + # -legacy: OpenSSL 3.x on macos-26 disables RC2-40-CBC; Apple p12 often uses it + echo "📋 Test 1: Checking p12 structure for private key..." + CERT_CONTENTS=$(openssl pkcs12 -in cert.p12 -legacy -nodes -passin env:IOS_P12_PASSWORD 2>&1) + OPENSSL_EXIT=$? + set -e + + if [ $OPENSSL_EXIT -ne 0 ]; then + echo "❌ ERROR: OpenSSL failed to read p12 (exit code $OPENSSL_EXIT)" + echo "" + echo "Output: $CERT_CONTENTS" + echo "" + echo "💡 Possible causes: wrong IOS_P12_PASSWORD, or OpenSSL 3 on macos-26 (we use -legacy for Apple p12)" + exit 1 + fi + + HAS_PRIVATE_KEY=true + + if echo "$CERT_CONTENTS" | grep -q "PRIVATE KEY"; then + echo "✅ Private key found in p12 certificate structure" + else + echo "❌ ERROR: p12 certificate does NOT contain a private key in its structure!" + HAS_PRIVATE_KEY=false + fi + + # Test 2: Check if private key can be extracted (nocerts flag) - informational only + echo "" + echo "📋 Test 2: Attempting to extract private key only (nocerts)..." + if openssl pkcs12 -in cert.p12 -legacy -nocerts -passin pass:"$IOS_P12_PASSWORD" >/dev/null 2>&1; then + echo "✅ Private key can be extracted separately" + else + echo "⚠️ Note: nocerts extraction failed (this is OK if Test 1 passed)" + fi + + # Test 3: Alternative private key check using env variable - informational only + echo "" + echo "📋 Test 3: Alternative private key extraction test..." + if openssl pkcs12 -in cert.p12 -legacy -nocerts -passin env:IOS_P12_PASSWORD >/dev/null 2>&1; then + echo "✅ Alternative extraction succeeded" + else + echo "⚠️ Note: Alternative extraction failed (this is OK if Test 1 passed)" + fi + + # Test 4: Show certificate details + echo "" + echo "📋 Test 4: Certificate details (subject and validity):" + CERT_SUBJECT=$(echo "$CERT_CONTENTS" | openssl x509 -noout -subject 2>/dev/null || echo "Could not extract subject") + CERT_DATES=$(echo "$CERT_CONTENTS" | openssl x509 -noout -dates 2>/dev/null || echo "Could not extract dates") + echo "$CERT_SUBJECT" + echo "$CERT_DATES" + + # Check if this is the correct certificate (Apple Distribution) + if echo "$CERT_SUBJECT" | grep -q "Apple Distribution"; then + echo "✅ Certificate type confirmed: Apple Distribution" + else + echo "⚠️ WARNING: Certificate may not be 'Apple Distribution' type" + fi + + # Test 5: List certificate and key components (double-check private key presence) + echo "" + echo "📋 Test 5: Certificate and key components found in p12:" + COMPONENTS=$(echo "$CERT_CONTENTS" | grep -E "BEGIN|END") + echo "$COMPONENTS" + + # Final validation: Ensure private key is present in the structure + if echo "$COMPONENTS" | grep -q "BEGIN PRIVATE KEY"; then + echo "" + echo "✅ FINAL CHECK: Private key structure confirmed in p12" + HAS_PRIVATE_KEY=true + elif echo "$COMPONENTS" | grep -q "BEGIN RSA PRIVATE KEY"; then + echo "" + echo "✅ FINAL CHECK: RSA private key structure confirmed in p12" + HAS_PRIVATE_KEY=true + elif [ "${HAS_PRIVATE_KEY}" = "false" ]; then + echo "" + echo "❌ VALIDATION FAILED: p12 does not contain a valid private key" + echo "" + echo "⚠️ Possible issues:" + echo " 1. The p12 was exported without including the private key" + echo " 2. Wrong password is being used (IOS_P12_PASSWORD)" + echo " 3. The certificate file is corrupted" + echo "" + echo "💡 To fix: Export the certificate from Keychain Access ensuring:" + echo " - Expand the certificate (click the triangle/arrow)" + echo " - Select both the certificate AND its private key (you should see 2 items)" + echo " - Right-click and choose 'Export 2 items...'" + echo " - Save as .p12 format" + exit 1 + fi + + echo "" + echo "✅ All validation tests passed - p12 contains a valid private key" + echo "" + echo "📝 Note: After Fastlane imports this certificate into the keychain," + echo " additional checks will verify:" + echo " - security find-certificate (certificate in keychain)" + echo " - security find-key (private key in keychain)" + echo " - security find-identity (valid signing identity)" + env: + IOS_P12_PASSWORD: ${{ secrets.IOS_P12_PASSWORD }} + + # 7️⃣ Decode provisioning profile (Fastlane will install it) + - name: Decode provisioning profile + run: | + # Check if the secret exists + if [ -z "$IOS_PROFILE_BASE64" ]; then + echo "❌ ERROR: IOS_PROFILE_BASE64 secret is missing or empty!" + exit 1 + fi + + # Decode provisioning profile + echo "$IOS_PROFILE_BASE64" | base64 --decode > profile.mobileprovision + + FILE_SIZE=$(stat -f%z profile.mobileprovision) + echo "✅ Provisioning profile size: $FILE_SIZE bytes" + env: + IOS_PROFILE_BASE64: ${{ secrets.IOS_PROFILE_BASE64 }} + + # 8️⃣ Setup App Store Connect API Key + - name: Setup App Store Connect API Key + run: | + # Check if secrets exist + if [ -z "$ASC_API_KEY_BASE64" ]; then + echo "❌ ERROR: ASC_API_KEY_BASE64 secret is missing or empty!" + exit 1 + fi + + if [ -z "${{ secrets.ASC_KEY_ID }}" ] || [ -z "${{ secrets.ASC_ISSUER_ID }}" ]; then + echo "❌ ERROR: ASC_KEY_ID or ASC_ISSUER_ID secret is missing!" + exit 1 + fi + + mkdir -p ~/.fastlane + echo "$ASC_API_KEY_BASE64" | base64 --decode > ~/.fastlane/AuthKey.p8 + chmod 600 ~/.fastlane/AuthKey.p8 + + # Verify the key file was created + if [ ! -f ~/.fastlane/AuthKey.p8 ]; then + echo "❌ ERROR: Failed to create AuthKey.p8" + exit 1 + fi + + echo "✅ App Store Connect API key configured" + env: + ASC_API_KEY_BASE64: ${{ secrets.ASC_API_KEY_BASE64 }} + + # 8b Set build number (Config.xcconfig overrides agvtool; must be > previous TestFlight build) + - 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 + echo "Set CURRENT_PROJECT_VERSION to $BUILD_NUM" + + # 9️⃣ Build & Upload to TestFlight via Fastlane (includes verification inside) + - name: Build & Upload to TestFlight + run: bundle exec fastlane beta + env: + 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 }} + + # 9️⃣ Upload build artifacts + - name: Upload IPA artifact + uses: actions/upload-artifact@v4 + if: success() + with: + name: ARK-Drop-${{ github.run_number }}.ipa + path: build/ARK-Drop.ipa + retention-days: 30 + + # 📋 Show build errors if build failed + - name: Show Build Log Tail + if: failure() + run: | + echo "🔍 Last 200 lines of build log:" + find build/logs -name "*.log" -exec tail -200 {} \; 2>/dev/null || echo "No build log found" + + echo "" + echo "🔍 Checking for error lines:" + find build/logs -name "*.log" -exec grep -i "error:" {} \; 2>/dev/null || echo "No errors found in log" + + # 📋 Upload build logs for debugging + - name: Upload Build Logs + uses: actions/upload-artifact@v4 + if: always() + with: + name: xcode-build-logs + path: | + ~/Library/Logs/gym/ + build/ + retention-days: 7 + + # 🔟 Cleanup + - name: Cleanup + if: always() + run: | + # Clean up certificate and profile files + rm -f cert.p12 profile.mobileprovision ~/.fastlane/AuthKey.p8 + # Fastlane's setup_ci will clean up its own keychain automatically diff --git a/.gitignore b/.gitignore index adfa9bf..87151ef 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,24 @@ captures !*.xcworkspace/contents.xcworkspacedata **/xcshareddata/WorkspaceSettings.xcsettings node_modules/ + +# 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..40734a7 --- /dev/null +++ b/Gemfile @@ -0,0 +1,4 @@ +source "https://rubygems.org" + +gem "fastlane", "~> 2.219" +gem "cocoapods", "~> 1.15" diff --git a/build.gradle.kts b/build.gradle.kts index ceb7696..2cc80a2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,4 +7,5 @@ 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 } 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..777b79c --- /dev/null +++ b/fastlane/Fastfile @@ -0,0 +1,185 @@ +# Fastfile for ARK Drop (KMP) iOS App + +default_platform(:ios) + +platform :ios do + # Environment variables + before_all do + setup_ci if ENV['CI'] + end + + desc "Push a new beta build to TestFlight" + lane :beta do + # Get the keychain path (setup_ci already created it) + keychain_name = "fastlane_tmp_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)" + + # 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: -k '' #{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} ~/Library/Keychains/login.keychain") + sh("security default-keychain -s #{keychain_path}") + sh("security unlock-keychain -p '' #{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" + } + ) + + 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..25eb6a1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -34,6 +34,7 @@ navigationCompose = "2.8.4" arkAbout = "0.2.1" materialIconsExtended = "1.7.8" ktlintGradlePlugin = "12.2.0" +skie = "0.10.9" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } @@ -107,3 +108,4 @@ 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" } 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..35c85f4 --- /dev/null +++ b/iosApp/README.md @@ -0,0 +1,207 @@ +# 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. 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..38bdb91 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -6,8 +6,14 @@ objectVersion = 77; objects = { +/* Begin PBXBuildFile section */ + 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 +47,38 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 87A139FB2F2824560011ECBC /* shared.xcframework in Frameworks */, + 87961AEE2F239F6600972E0D /* ArkDrop 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 +102,7 @@ ); name = iosApp; packageProductDependencies = ( + 87961AED2F239F6600972E0D /* ArkDrop */, ); productName = iosApp; productReference = 19D5789066F6284BA1F79245 /* ARK-Drop.app */; @@ -114,6 +132,9 @@ ); mainGroup = 7C29D5070F6F808BD9FE5AB5; minimizedProjectReferenceProxies = 1; + packageReferences = ( + 87961AEC2F239F6600972E0D /* XCRemoteSwiftPackageReference "arkdrop-swift-binaries" */, + ); preferredProjectObjectVersion = 77; productRefGroup = 7BA83B6A3A88D023B32D48A4 /* Products */; projectDirPath = ""; @@ -152,7 +173,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 +188,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 +262,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 +288,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 +362,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 +381,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 +403,25 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 87961AEC2F239F6600972E0D /* XCRemoteSwiftPackageReference "arkdrop-swift-binaries" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/oluiscabral/arkdrop-swift-binaries"; + requirement = { + branch = main; + kind = branch; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 87961AED2F239F6600972E0D /* ArkDrop */ = { + isa = XCSwiftPackageProductDependency; + package = 87961AEC2F239F6600972E0D /* XCRemoteSwiftPackageReference "arkdrop-swift-binaries" */; + 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..424a0a8 --- /dev/null +++ b/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "9f16321347f03f49a36fc97e9dd599134b60317bb02cf8d52607a05cc7df5ba1", + "pins" : [ + { + "identity" : "arkdrop-swift-binaries", + "kind" : "remoteSourceControl", + "location" : "https://github.com/oluiscabral/arkdrop-swift-binaries", + "state" : { + "branch" : "main", + "revision" : "e57f102d36001152f41862fe140e1e2f243c02a4" + } + } + ], + "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/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..e800d10 --- /dev/null +++ b/iosApp/iosApp/Core/AppConfiguration.swift @@ -0,0 +1,26 @@ +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() + 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..855c323 --- /dev/null +++ b/iosApp/iosApp/Core/NavigationCoordinator.swift @@ -0,0 +1,32 @@ +import SwiftUI + +enum Route: Hashable { + case home + case send + case receive + case history + case editProfile + case about +} + +@MainActor +class NavigationCoordinator: ObservableObject { + @Published var path = NavigationPath() + + func navigate(to route: Route) { + path.append(route) + } + + func navigateBack() { + path.removeLast() + } + + func navigateToRoot() { + path.removeLast(path.count) + } + + func replace(with route: Route) { + path.removeLast() + path.append(route) + } +} 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..944c34d --- /dev/null +++ b/iosApp/iosApp/Features/Home/HomeView.swift @@ -0,0 +1,298 @@ +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) + } + } + } + + 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..bd486ca --- /dev/null +++ b/iosApp/iosApp/Features/Receive/QRScannerView.swift @@ -0,0 +1,193 @@ +import SwiftUI +import AVFoundation + +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: \(code)") + + // 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: \(code)") + 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 ticket or confirmation in QR code") + return + } + + print("✅ Parsed QR: ticket=\(ticket), confirmation=\(confirmation)") + 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 { + return + } + + guard let videoInput = try? AVCaptureDeviceInput(device: videoCaptureDevice) else { + 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 + self?.session.startRunning() + } + } + + func stopScanning() { + sessionQueue.async { [weak self] in + 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: \(ticket) \(conf)") + }, + onCancel: {} + ) +} diff --git a/iosApp/iosApp/Features/Receive/ReceiveView.swift b/iosApp/iosApp/Features/Receive/ReceiveView.swift new file mode 100644 index 0000000..21e0f3d --- /dev/null +++ b/iosApp/iosApp/Features/Receive/ReceiveView.swift @@ -0,0 +1,620 @@ +import SwiftUI +import Shared +import AVFoundation +import Combine + +struct ReceiveView: View { + @StateObject private var viewModel = ReceiveViewModelWrapper() + @EnvironmentObject private var coordinator: NavigationCoordinator + + 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 + ) + + case is ReceiveScreenState.RequestingPermission: + LoadingView(message: "Requesting camera permission...") + + case is ReceiveScreenState.Scanning: + QRScannerView( + onCodeScanned: viewModel.onQrCodeScanned, + onCancel: viewModel.onStopScanning + ) + + 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 + ) + + case let state as ReceiveScreenState.QRCodeScanned: + QRScannedView( + ticket: state.ticket, + confirmation: state.confirmation, + onAccept: viewModel.onAccept, + onCancel: viewModel.onScanAgain + ) + + case is ReceiveScreenState.Connecting: + LoadingView(message: "Connecting to sender...") + + 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 + ) + + case let state as ReceiveScreenState.Error: + ReceiveErrorView( + error: state.error, + onRetry: viewModel.onErrorRetry, + onDismiss: viewModel.onErrorDismiss + ) + + 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: + requestCameraPermission() + + case is ReceiveScreenEffect.NavigateBack: + coordinator.navigateBack() + + case is ReceiveScreenEffect.HideKeyboard: + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + + default: + break + } + } + + private func requestCameraPermission() { + AVCaptureDevice.requestAccess(for: .video) { granted in + Task { @MainActor in + 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 + + 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: onAccept, + style: .primary + ) + + DropButton( + title: "Scan Again", + action: onCancel, + style: .outline + ) + } + .frame(maxWidth: 280) + } + .padding(Spacing.lg) + } +} + +// MARK: - Receiving + +struct ReceivingView: View { + let state: ReceiveScreenState.Receiving + let onCancel: () -> Void + + 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) + } + } + + // Files Progress + if !state.progress.files.isEmpty { + VStack(spacing: Spacing.md) { + ForEach(state.progress.files, id: \.id) { file in + FileProgressRow( + fileName: file.name, + totalSize: Int64(file.size), + receivedBytes: state.progress.fileProgress[file.id]?.receivedBytes ?? 0, + isComplete: state.progress.fileProgress[file.id]?.isComplete ?? false + ) + } + } + .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: 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 + + 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: onReceiveMore, + style: .primary + ) + + DropButton( + title: "Done", + action: onDone, + style: .outline + ) + } + .frame(maxWidth: 280) + } + .padding(Spacing.lg) + } +} + +// MARK: - Error View + +struct ReceiveErrorView: View { + let error: ReceiveError + 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 .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 var stateTask: Task? + private var effectTask: Task? + + init() { + self.viewModel = DIContainer.shared.makeReceiveViewModel() + self.state = ReceiveScreenState.Initial(cameraPermissionGranted: 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 { + print("📡 ReceiveViewModel state changed: \(type(of: newState))") + self.state = newState as! ReceiveScreenState + } + } catch { + print("❌ ReceiveViewModel 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? ReceiveScreenEffect { + self.effectPublisher.send(typedEffect) + } + } + } catch { + print("ReceiveViewModel effect error: \(error)") + } + } + } + + func onStartScanning() { + viewModel.onStartScanning() + } + + func onStopScanning() { + viewModel.onStopScanning() + } + + func onEnterManually() { + viewModel.onEnterManually() + } + + func onRequestCameraPermission() { + viewModel.onRequestCameraPermission() + } + + func onCameraPermissionGranted(_ granted: Bool) { + viewModel.onCameraPermissionGranted(isGranted: granted) + } + + func onQrCodeScanned(ticket: String, confirmation: UInt8) { + print("🔄 ReceiveViewModelWrapper.onQrCodeScanned: ticket=\(ticket), confirmation=\(confirmation)") + viewModel.onQrCodeScanned(ticket: ticket, confirmation: confirmation) + print("✅ Called viewModel.onQrCodeScanned") + } + + func onManualInputChanged(_ input: String) { + viewModel.onManualInputChanged(input: input) + } + + func handleManualInputSubmit() { + viewModel.handleManualInputSubmit() + } + + func onPasteFromClipboard(_ text: String?) { + viewModel.onPasteFromClipboard(clipText: text) + } + + func onAccept() { + viewModel.onAccept() + } + + func onCancelReceiving() { + viewModel.onCancelReceiving() + } + + func onCancelManualInput() { + viewModel.onCancelManualInput() + } + + func onScanAgain() { + viewModel.onScanAgain() + } + + func onReceiveMore() { + viewModel.onReceiveMore() + } + + func onDone() { + viewModel.onDone() + } + + func onErrorRetry() { + viewModel.onErrorRetry() + } + + func onErrorDismiss() { + viewModel.onErrorDismiss() + } + + 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..970d8a8 --- /dev/null +++ b/iosApp/iosApp/Features/Send/SendView.swift @@ -0,0 +1,578 @@ +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 + + 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 + ) + + case let state as SendScreenState.GeneratingQR: + LoadingView( + message: "Generating QR Code...", + canCancel: true, + onCancel: viewModel.onCancelQrGeneration + ) + + case let state as SendScreenState.WaitingForReceiver: + WaitingForReceiverView( + state: state, + onCancel: viewModel.onCancelTransfer + ) + + case let state as SendScreenState.Transfer: + TransferringView( + state: state, + onCancel: viewModel.onCancelTransfer + ) + + case let state as SendScreenState.Complete: + TransferCompleteView( + fileCount: state.files.count, + onSendMore: viewModel.onSendMore, + onDone: viewModel.onDone + ) + + case let state as SendScreenState.Error: + SendErrorView( + error: state.error, + onRetry: viewModel.onErrorRetry, + onDismiss: viewModel.onErrorDismiss + ) + + 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 + var accessiblePaths: [String] = [] + for url in urls { + if url.startAccessingSecurityScopedResource() { + accessiblePaths.append(url.path) + viewModel.trackAccessedURL(url) + } + } + viewModel.onFilesAdded(accessiblePaths) + case .failure(let error): + 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 + }) { + 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 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) + 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 { + self.state = newState as! SendScreenState + } + } catch { + 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 { + self.effectPublisher.send(typedEffect) + } + } + } catch { + print("SendViewModel effect error: \(error)") + } + } + } + + func onFilesAdded(_ files: [String]) { + viewModel.onFilesAdded(newFiles: files) + } + + func onFileRemove(_ file: String) { + viewModel.onFileRemove(file: file) + } + + func onStartTransfer() { + viewModel.onStartTransfer() + } + + func onCancelTransfer() { + viewModel.onCancelTransfer() + } + + func onCancelQrGeneration() { + viewModel.onCancelQrGeneration() + } + + func onSendMore() { + viewModel.onSendMore() + } + + func onDone() { + viewModel.onDone() + } + + func onErrorRetry() { + viewModel.onErrorRetry() + } + + func onErrorDismiss() { + viewModel.onErrorDismiss() + } + + deinit { + // Release security-scoped resource access + accessedURLs.forEach { $0.stopAccessingSecurityScopedResource() } + accessedURLs.removeAll() + + stateTask?.cancel() + effectTask?.cancel() + } +} + +#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..3ce0f1f --- /dev/null +++ b/iosApp/iosApp/Utilities/Extensions.swift @@ -0,0 +1,33 @@ +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: - 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..dea3a24 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -1,10 +1,26 @@ import SwiftUI +import Shared @main struct iOSApp: App { + init() { + // Initialize app configuration and DI + 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..b6bc664 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,111 @@ kotlin { } } + val xcf = XCFramework() + listOf( + iosX64(), iosArm64(), iosSimulatorArm64(), ).forEach { iosTarget -> iosTarget.binaries.framework { baseName = "Shared" isStatic = true + xcf.add(this) + } + + // Configure cinterop for ArkDrop bridge + iosTarget.compilations.getByName("main") { + val arkDropBridgeCinterop = cinterops.create("ArkDropBridge") { + defFile(project.file("src/nativeInterop/cinterop/ArkDropBridge.def")) + packageName("dev.arkbuilders.drop.bridge") + + // Add include paths - use File objects for proper resolution + val iosAppPath = rootProject.projectDir.resolve("iosApp/iosApp") + compilerOpts( + "-framework", "Foundation", + "-I${iosAppPath.absolutePath}" + ) + + // Specify where to find headers + includeDirs(iosAppPath.absolutePath) + } + + // Ensure cinterop runs before Kotlin compilation + compileTaskProvider.configure { + val cinteropTaskName = "cinteropArkDropBridge${iosTarget.name.replaceFirstChar { it.uppercase() }}" + dependsOn(cinteropTaskName) + } + } + + // 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) + } } - 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(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 +145,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/commonMain/kotlin/dev/arkbuilders/drop/domain/usecase/ReceiveFilesUseCase.kt b/shared/src/commonMain/kotlin/dev/arkbuilders/drop/domain/usecase/ReceiveFilesUseCase.kt index 6c1ff3c..4dc3eda 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 @@ -30,6 +30,10 @@ class ReceiveFilesUseCase( 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,8 +41,8 @@ class ReceiveFilesUseCase( profile = receiverProfile, config = DropReceiverConfig( - chunkSize = 1024u * 512u, - parallelStreams = 4u, + chunkSize = chunkSize.toULong(), + parallelStreams = parallelStreams.toULong(), ), ) 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..46272e0 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 @@ -50,14 +50,18 @@ class SendFilesUseCase( error("No valid files to send") } + // 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(), ), ) 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..598babd 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,74 @@ +@file:OptIn(ExperimentalForeignApi::class) + package dev.arkbuilders.drop.data.helper +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.useContents +import platform.Foundation.* +import platform.UIKit.* +import platform.CoreGraphics.* + 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..ea0b24a 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,91 @@ +@file:OptIn(ExperimentalForeignApi::class) + package dev.arkbuilders.drop.data.helper +import kotlinx.cinterop.* +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import platform.Foundation.* +import platform.SystemConfiguration.* +import platform.darwin.* 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 } - actual val onlineStatus: StateFlow - get() { - throw NotImplementedError() + 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 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..7c7b98e 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,23 @@ +@file:OptIn(ExperimentalForeignApi::class) + package dev.arkbuilders.drop.data.helper +import kotlinx.cinterop.ExperimentalForeignApi +import platform.AVFoundation.* 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..f60823a 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,192 @@ +@file:OptIn(ExperimentalForeignApi::class) + package dev.arkbuilders.drop.data.helper import dev.arkbuilders.drop.domain.libwrapper.send.request.DropSenderFileData +import dev.arkbuilders.drop.domain.libwrapper.send.SenderFileDataImpl +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.usePinned +import kotlinx.cinterop.useContents +import kotlinx.cinterop.addressOf +import platform.Foundation.* +import platform.UIKit.* +import platform.CoreImage.* +import platform.CoreGraphics.* +import kotlin.IllegalArgumentException actual class ResourcesHelper { actual fun getFileName(uri: String): String? { - throw NotImplementedError() + return try { + val url = NSURL.fileURLWithPath(uri) + ?: NSURL.URLWithString(uri) + ?: return null + url.lastPathComponent + } catch (e: Exception) { + 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) + } else { + skippedCount++ + } + } catch (_: Exception) { + 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) + + return try { + val fileManager = NSFileManager.defaultManager + val documentsPath = fileManager.URLForDirectory( + directory = NSDocumentDirectory, + inDomain = NSUserDomainMask, + appropriateForURL = null, + create = true, + error = null + ) ?: return null + + val fileURL = documentsPath.URLByAppendingPathComponent(uniqueName) ?: return null + val nsData = data.usePinned { pinned -> + NSData.dataWithBytes(pinned.addressOf(0), data.size.toULong()) + } + nsData.writeToURL(fileURL, atomically = true) + + uniqueName + } catch (e: Exception) { + null + } } actual fun generateQRCode( ticket: String, confirmation: UByte, ): ByteArray? { - throw NotImplementedError() + return try { + if (ticket.isEmpty()) { + 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") + + val outputImage = filter?.outputImage ?: return null + + // Scale up the QR code for better quality + 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) + + pngData?.let { + val length = it.length.toInt() + val bytes = ByteArray(length) + bytes.usePinned { pinned -> + it.getBytes(pinned.addressOf(0), length = length.toULong()) + } + bytes + } + } catch (e: Throwable) { + null + } } actual fun mapToSenderFileData(uri: String): DropSenderFileData { - throw NotImplementedError() + 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..9d44244 --- /dev/null +++ b/shared/src/iosMain/kotlin/dev/arkbuilders/drop/di/KoinHelper.kt @@ -0,0 +1,40 @@ +package dev.arkbuilders.drop.di + +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 + } +} 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..2ae73a5 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,6 +7,10 @@ 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 kotlinx.cinterop.ExperimentalForeignApi @@ -20,6 +24,10 @@ import platform.Foundation.NSUserDomainMask actual val platformModule: Module = module { + single { AvatarHelper() } + single { NetworkStatus() } + single { PermissionsHelper() } + single { ResourcesHelper() } 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..e7b7688 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,37 @@ package dev.arkbuilders.drop.domain.libwrapper +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 { + return DropSendFilesSubscriberImpl(SendFilesSubscriberImpl()) + } + + override suspend fun sendFiles(request: DropSendFilesRequest): DropSendFilesBubble { + // Use the bridge wrapper to call the Objective-C bridge + return ArkDropBridgeWrapper.sendFiles(request) + } + + override fun createReceiveSubscriber(): DropReceiveFilesSubscriber { + return DropReceiveFilesSubscriberImpl(ReceiveFilesSubscriberImpl()) + } + + override suspend fun receiveFiles(request: DropReceiveFilesRequest): DropReceiveFilesBubble { + // Use the bridge wrapper to call the Objective-C bridge + return ArkDropBridgeWrapper.receiveFiles(request) + } +} + actual fun getDropApi(): DropApi { - throw NotImplementedError() + 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..f943377 --- /dev/null +++ b/shared/src/iosMain/kotlin/dev/arkbuilders/drop/domain/libwrapper/bridge/ArkDropBridgeWrapper.ios.kt @@ -0,0 +1,309 @@ +@file:OptIn(ExperimentalForeignApi::class) + +package dev.arkbuilders.drop.domain.libwrapper.bridge + +import dev.arkbuilders.drop.bridge.* +import dev.arkbuilders.drop.domain.libwrapper.receive.DropReceiveFilesBubble +import dev.arkbuilders.drop.domain.libwrapper.receive.DropReceiveFilesSubscriber +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.* +import platform.Foundation.* +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 { + 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) + 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>() + + dev.arkbuilders.drop.bridge.ArkDropBridge.sendFilesWithRequest(bridgeRequest, bubble = bubblePtr.ptr, error = errorPtr.ptr) + + val error = errorPtr.value + if (error != null) { + throw Exception("Failed to send files: ${error.localizedDescription}") + } + + val bubble = bubblePtr.value ?: throw Exception("Failed to create send bubble") + ArkDropSendFilesBubbleWrapper(bubble) + } + } + } + + suspend fun receiveFiles(request: DropReceiveFilesRequest): DropReceiveFilesBubble { + 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>() + + dev.arkbuilders.drop.bridge.ArkDropBridge.receiveFilesWithRequest(bridgeRequest, bubble = bubblePtr.ptr, error = errorPtr.ptr) + + val error = errorPtr.value + if (error != null) { + throw Exception("Failed to receive files: ${error.localizedDescription}") + } + + val bubble = bubblePtr.value ?: throw Exception("Failed to create receive bubble") + 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(), dev.arkbuilders.drop.bridge.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: dev.arkbuilders.drop.bridge.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: dev.arkbuilders.drop.bridge.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(), dev.arkbuilders.drop.bridge.ArkDropSendFilesSubscriberProtocol { + private val native = (subscriber as? dev.arkbuilders.drop.domain.libwrapper.send.DropSendFilesSubscriberImpl) + ?.native as? dev.arkbuilders.drop.domain.libwrapper.send.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) { + 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(), dev.arkbuilders.drop.bridge.ArkDropReceiveFilesSubscriberProtocol { + private val native = (subscriber as? dev.arkbuilders.drop.domain.libwrapper.receive.DropReceiveFilesSubscriberImpl) + ?.native as? dev.arkbuilders.drop.domain.libwrapper.receive.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) { + 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._progress.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..28720c7 --- /dev/null +++ b/shared/src/iosMain/kotlin/dev/arkbuilders/drop/domain/libwrapper/receive/DropReceiveFilesBubbleImpl.ios.kt @@ -0,0 +1,95 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + +package dev.arkbuilders.drop.domain.libwrapper.receive + +import dev.arkbuilders.drop.bridge.* +import kotlinx.cinterop.* +import platform.Foundation.* +import platform.darwin.NSObject + +class DropReceiveFilesBubbleImpl( + private val bubble: ArkDropReceiveFilesBubbleProtocol, +) : DropReceiveFilesBubble { + override fun cancel() { + bubble.cancel() + } + + override fun isCancelled(): Boolean { + return bubble.isCancelled() + } + + override fun isFinished(): Boolean { + return 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: ${error.localizedDescription}") + } + } + } + + override fun subscribe(subscriber: DropReceiveFilesSubscriber) { + val adapter = ArkDropReceiveFilesSubscriberAdapter(subscriber) + bubble.subscribeWithSubscriber(adapter) + } + + override fun unsubscribe(subscriber: DropReceiveFilesSubscriber) { + val adapter = ArkDropReceiveFilesSubscriberAdapter(subscriber) + bubble.unsubscribeWithSubscriber(adapter) + } +} + +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() + 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<*> + ) { + 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 + ) + } + + val currentProgress = native.progress.value + native._progress.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..695930b --- /dev/null +++ b/shared/src/iosMain/kotlin/dev/arkbuilders/drop/domain/libwrapper/receive/DropReceiveFilesSubscriberImpl.ios.kt @@ -0,0 +1,97 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + +package dev.arkbuilders.drop.domain.libwrapper.receive + +import kotlinx.cinterop.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import platform.Foundation.* +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 _progress = MutableStateFlow(DropReceivingProgress()) + val progress: StateFlow = _progress.asStateFlow() + + fun getId(): String = id + + fun log(message: String) { + // On iOS, we can use NSLog or a logging framework + // For now, empty implementation as requested + } + + fun reset() { + // Clear all data streams + receivedDataMap.clear() + _progress.value = DropReceivingProgress() + } + + /** + * Get files that have been completely received + */ + fun getCompleteFiles(): List> { + val currentProgress = _progress.value + return 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() + val bytes = ByteArray(length) + bytes.usePinned { pinned -> + data.getBytes(pinned.addressOf(0), length = length.toULong()) + } + Pair(fileInfo, bytes) + } else { + null + } + } else { + null + } + } + } + + /** + * Helper method to append received data directly (for bridge use) + */ + fun appendReceivedData(fileId: String, data: ByteArray) { + 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 = _progress.value + val fileInfo = currentProgress.files.find { it.id == fileId } + + if (fileInfo != null) { + val receivedBytes = existingData.length.toLong() + val isComplete = receivedBytes.toULong() >= fileInfo.size + + val updatedFileProgress = currentProgress.fileProgress.toMutableMap() + updatedFileProgress[fileId] = FileProgressInfo( + receivedBytes = receivedBytes, + isComplete = isComplete + ) + + _progress.value = currentProgress.copy( + fileProgress = updatedFileProgress.toMap() + ) + } + } +} 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..d4fedda --- /dev/null +++ b/shared/src/iosMain/kotlin/dev/arkbuilders/drop/domain/libwrapper/send/DropSendFilesBubbleImpl.ios.kt @@ -0,0 +1,87 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + +package dev.arkbuilders.drop.domain.libwrapper.send + +import dev.arkbuilders.drop.bridge.* +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlinx.coroutines.Dispatchers +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import platform.darwin.NSObject + +class DropSendFilesBubbleImpl( + private val bubble: ArkDropSendFilesBubbleProtocol, +) : DropSendFilesBubble { + override suspend fun cancel() { + withContext(Dispatchers.Main) { + suspendCancellableCoroutine { cont -> + bubble.cancelWithCompletion { error -> + if (error != null) { + cont.resumeWithException(Exception(error.localizedDescription)) + } else { + cont.resume(Unit) + } + } + cont.invokeOnCancellation { + // Handle cancellation if needed + } + } + } + } + + override fun getConfirmation(): UByte { + return bubble.getConfirmation() + } + + override fun getCreatedAt(): String { + return bubble.getCreatedAt() + } + + override fun getTicket(): String { + return bubble.getTicket() + } + + override fun isConnected(): Boolean { + return bubble.isConnected() + } + + override fun isFinished(): Boolean { + return bubble.isFinished() + } + + override fun subscribe(subscriber: DropSendFilesSubscriber) { + val adapter = ArkDropSendFilesSubscriberAdapter(subscriber) + bubble.subscribeWithSubscriber(adapter) + } + + override fun unsubscribe(subscriber: DropSendFilesSubscriber) { + val adapter = ArkDropSendFilesSubscriberAdapter(subscriber) + bubble.unsubscribeWithSubscriber(adapter) + } +} + +/** + * 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) { + native.updateSendingProgress(name, sent, remaining) + } + + override fun notifyConnectingWithReceiverName(receiverName: String, receiverAvatarB64: String?) { + 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..b7df4d6 --- /dev/null +++ b/shared/src/iosMain/kotlin/dev/arkbuilders/drop/domain/libwrapper/send/SenderFileDataImpl.ios.kt @@ -0,0 +1,115 @@ + +@file:OptIn(ExperimentalForeignApi::class) +package dev.arkbuilders.drop.domain.libwrapper.send + +import dev.arkbuilders.drop.domain.libwrapper.send.request.DropSenderFileData +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.usePinned +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.reinterpret +import platform.Foundation.* + +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 + + try { + println("📁 SenderFileDataImpl: Initializing file: $uri") + + // Try as file path first, then as URL string + val url = NSURL.fileURLWithPath(uri) + ?: NSURL.URLWithString(uri) + ?: run { + println("⚠️ SenderFileDataImpl: Failed to create URL from: $uri") + return + } + + println("📁 SenderFileDataImpl: Created URL: ${url.absoluteString}") + + // 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") + } ?: println("⚠️ 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) { + println("⚠️ SenderFileDataImpl: Stream error: ${inputStream?.streamError?.localizedDescription}") + return + } + + isInitialized = true + println("✅ SenderFileDataImpl: Successfully initialized") + } catch (e: Exception) { + println("❌ SenderFileDataImpl: Failed to initialize file: $uri, error: $e") + } + } + + override fun len(): ULong { + initialize() + return totalLength + } + + override fun read(): UByte? { + initialize() + if (!isInitialized) { + println("⚠️ SenderFileDataImpl.read() - not initialized for $uri") + return null + } + return try { + val buffer = UByteArray(1) + val bytesRead = buffer.usePinned { pinned -> + inputStream?.read(pinned.addressOf(0).reinterpret(), maxLength = 1u)?.toLong() ?: 0L + } + if (bytesRead == 0L) { + inputStream?.close() + null + } else { + buffer[0] + } + } catch (e: Exception) { + println("⚠️ SenderFileDataImpl.read() error for $uri: $e") + null + } + } + + override fun readChunk(size: Int): ByteArray { + initialize() + if (!isInitialized) { + println("⚠️ SenderFileDataImpl.readChunk() - not initialized for $uri") + return ByteArray(0) + } + return try { + val buffer = UByteArray(size) + val bytesRead = buffer.usePinned { pinned -> + inputStream?.read(pinned.addressOf(0).reinterpret(), maxLength = size.toULong())?.toLong() ?: 0L + } + if (bytesRead == 0L) { + inputStream?.close() + ByteArray(0) + } else { + buffer.asByteArray().copyOf(bytesRead.toInt()) + } + } catch (e: Exception) { + println("⚠️ SenderFileDataImpl.readChunk() error for $uri: $e") + 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..ed0064a --- /dev/null +++ b/shared/src/iosMain/kotlin/dev/arkbuilders/drop/domain/libwrapper/send/SenderFilesSubscriberImpl.ios.kt @@ -0,0 +1,57 @@ +package dev.arkbuilders.drop.domain.libwrapper.send + +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 + // For now, empty implementation as requested + } + + // Note: These methods are called via the adapter from the bridge + // The original event-based methods are no longer used directly + + fun reset() { + _progress.value = DropSendingProgress() + } + + /** + * Helper method to update sending progress directly (for bridge use) + */ + fun updateSendingProgress(fileName: String, sent: ULong, remaining: ULong) { + _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?) { + _progress.value = _progress.value.copy( + isConnected = true, + receiverName = receiverName, + receiverAvatar = receiverAvatar + ) + } +} 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