diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d735f8d5..0630ae7c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -99,16 +99,36 @@ jobs: submodules: 'recursive' + - name: Detect Fastlane secrets + id: fastlane_secrets + env: + KEY_FASTLANE_API: ${{ secrets.KEY_FASTLANE_API }} + run: | + if [ -n "$KEY_FASTLANE_API" ]; then + echo "available=true" >> "$GITHUB_OUTPUT" + else + echo "available=false" >> "$GITHUB_OUTPUT" + fi + + - name: Require Fastlane secrets outside pull requests + if: steps.fastlane_secrets.outputs.available != 'true' && github.event_name != 'pull_request' + run: | + echo "::error::KEY_FASTLANE_API is required for non-PR builds" + exit 1 + # Ruby & Fastlane # version set by .ruby-version - name: Set up Ruby and install fastlane + if: steps.fastlane_secrets.outputs.available == 'true' uses: ruby/setup-ruby@v1 with: bundler-cache: true # Needed for `fastlane update_version` - uses: adnsio/setup-age-action@v1.2.0 + if: steps.fastlane_secrets.outputs.available == 'true' - name: Load Fastlane secrets + if: steps.fastlane_secrets.outputs.available == 'true' env: KEY_FASTLANE_API: ${{ secrets.KEY_FASTLANE_API }} run: | @@ -121,12 +141,18 @@ jobs: # Retry this, in case there are concurrent jobs, which may lead to the error: # "Google Api Error: Invalid request - This Edit has been deleted." - name: Update versionCode + if: steps.fastlane_secrets.outputs.available == 'true' uses: Wandalen/wretry.action@master with: command: bundle exec fastlane update_version attempt_limit: 3 attempt_delay: 20000 + - name: Use checked-in versionCode fallback + if: steps.fastlane_secrets.outputs.available != 'true' + run: | + echo "KEY_FASTLANE_API unavailable for pull_request build; using checked-in versionCode." + - name: Output versionCode id: versionCode run: | @@ -136,6 +162,9 @@ jobs: name: Build ${{ matrix.type }} runs-on: ubicloud-standard-4 needs: [build-rust, get-versionCode] + # Fork PRs cannot access signing secrets; build/test coverage still runs in + # build-rust, test, and test-e2e. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository strategy: fail-fast: true matrix: @@ -222,15 +251,14 @@ jobs: make dist/aw-android.${{ matrix.type }} - name: Upload - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: - name: aw-android + name: aw-android-${{ matrix.type }} path: dist/aw-android*.${{ matrix.type }} test: name: Test - runs-on: ubuntu-20.04 - needs: [build-rust] + runs-on: ubuntu-22.04 env: SUPPLY_TRACK: production # used by fastlane to determine track to publish to @@ -239,40 +267,20 @@ jobs: with: submodules: 'recursive' - - name: Set RELEASE - run: | - echo "RELEASE=${{ startsWith(github.ref_name, 'v') }}" >> $GITHUB_ENV - - name: Set up JDK uses: actions/setup-java@v1 with: java-version: ${{ env.JAVA_VERSION }} - # Android SDK & NDK + # Android SDK & NDK (NDK required at Gradle configuration time even for JVM unit tests) - name: Set up Android SDK uses: android-actions/setup-android@v2 - name: Set up Android NDK run: | sdkmanager "ndk;${{ env.NDK_VERSION }}" ANDROID_NDK_HOME="$ANDROID_SDK_ROOT/ndk/${{ env.NDK_VERSION }}" - ls $ANDROID_NDK_HOME echo "ANDROID_NDK_HOME=$ANDROID_NDK_HOME" >> $GITHUB_ENV - - # Restores jniLibs from cache - # `actions/cache/restore` only restores, without saving back in a post-hook - - uses: actions/cache/restore@v3 - id: cache-jniLibs - env: - cache-name: jniLibs - with: - path: mobile/src/main/jniLibs/ - key: ${{ env.cache-name }}-release-${{ env.RELEASE }}-ndk-${{ env.NDK_VERSION }}-${{ hashFiles('.git/modules/aw-server-rust/HEAD') }} - fail-on-cache-miss: true - - name: Check that jniLibs present - run: | - test -e mobile/src/main/jniLibs/x86_64/libaw_server.so - # Test - name: Test run: | @@ -328,15 +336,18 @@ jobs: if: runner.os == 'macOS' run: brew install intel-haxm + - name: Set up Android SDK + uses: android-actions/setup-android@v2 + # # # Below code is majorly from https://github.com/actions/runner-images/issues/6152#issuecomment-1243718140 - name: Create Android emulator run: | # Install AVD files - echo "y" | $ANDROID_HOME/tools/bin/sdkmanager --install 'system-images;android-'$MATRIX_E_SDK';default;x86_64' - echo "y" | $ANDROID_HOME/tools/bin/sdkmanager --licenses + echo "y" | sdkmanager --install 'system-images;android-'$MATRIX_E_SDK';default;x86_64' + echo "y" | sdkmanager --licenses # Create emulator - $ANDROID_HOME/tools/bin/avdmanager create avd -n $MATRIX_AVD -d pixel --package 'system-images;android-'$MATRIX_E_SDK';default;x86_64' + avdmanager create avd -n $MATRIX_AVD -d pixel --package 'system-images;android-'$MATRIX_E_SDK';default;x86_64' $ANDROID_HOME/emulator/emulator -list-avds if false; then emulator_config=~/.android/avd/$MATRIX_AVD.avd/config.ini @@ -369,14 +380,19 @@ jobs: run: | echo "Starting emulator and waiting for boot to complete...." ls -la $ANDROID_HOME/emulator - $ANDROID_HOME/tools/emulator --accel-check # check for hardware acceleration - nohup $ANDROID_HOME/tools/emulator -avd $MATRIX_AVD -gpu host -no-audio -no-boot-anim -camera-back none -camera-front none -qemu -m 2048 2>&1 & + emulator_args=(-avd "$MATRIX_AVD" -gpu swiftshader_indirect -no-window -no-audio -no-boot-anim -camera-back none -camera-front none) + if $ANDROID_HOME/emulator/emulator -accel-check; then + echo "Hardware acceleration available." + else + echo "Hardware acceleration unavailable; using software acceleration." + emulator_args+=(-accel off) + fi + nohup $ANDROID_HOME/emulator/emulator "${emulator_args[@]}" -qemu -m 2048 2>&1 & $ANDROID_HOME/platform-tools/adb wait-for-device shell 'while [[ -z $(getprop sys.boot_completed | tr -d '\r') ]]; do echo "wait..."; sleep 1; done; input keyevent 82' echo "Emulator has finished booting" $ANDROID_HOME/platform-tools/adb devices sleep 30 mkdir -p screenshots - screencapture screenshots/screenshot-$SUFFIX.jpg $ANDROID_HOME/platform-tools/adb exec-out screencap -p > screenshots/emulator-$SUFFIX.png # # # Have to re-setup everything since we need to run emulator for faster performance on masOS ? Other os'es emulator will not startup ? @@ -422,8 +438,6 @@ jobs: java-version: ${{ env.JAVA_VERSION }} # Android SDK & NDK - - name: Set up Android SDK - uses: android-actions/setup-android@v2 - name: Set up Android NDK run: | sdkmanager "ndk;${{ env.NDK_VERSION }}" @@ -449,15 +463,15 @@ jobs: env: SUFFIX: ${{ matrix.android_avd }}-eAPI-${{ matrix.android_emu_version }} run: | - adb shell monkey -p net.activitywatch.android.debug 1 + adb shell monkey -p net.activitywatch.android.debug 1 || echo "App launch failed; capturing current emulator screen." sleep 10 - screencapture screenshots/pscreenshot-$SUFFIX.jpg - $ANDROID_HOME/platform-tools/adb exec-out screencap -p > screenshots/pemulator-$SUFFIX.png + mkdir -p screenshots + $ANDROID_HOME/platform-tools/adb exec-out screencap -p > screenshots/pemulator-$SUFFIX.png || echo "Screenshot capture failed." ls -alh screenshots/ - name: Upload logcat if: ${{ success() || steps.test.conclusion == 'failure'}} - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: logcat # mobile\build\outputs\connected_android_test_additional_output\debugAndroidTest\connected\Pixel_XL_API_32(AVD) - 12\ScreenshotTest_saveDeviceScreenBitmap.png @@ -473,7 +487,7 @@ jobs: # path: ./*.mov # out.mov - name: Upload screenshots - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: ${{ success() || steps.test.conclusion == 'failure'}} with: name: screenshots @@ -500,10 +514,11 @@ jobs: - uses: actions/checkout@v3 - name: Download APK & AAB - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: - name: aw-android + pattern: aw-android-* path: dist + merge-multiple: true - name: Display structure of downloaded files working-directory: dist @@ -556,10 +571,11 @@ jobs: # Will download all artifacts to path - name: Download release APK & AAB - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: - name: aw-android + pattern: aw-android-* path: dist + merge-multiple: true - name: Display structure of downloaded files working-directory: dist @@ -581,4 +597,3 @@ jobs: dist/*.apk dist/*.aab # body_path: dist/release_notes/release_notes.md - diff --git a/aw-server-rust b/aw-server-rust index dc70318e..0c8b2adf 160000 --- a/aw-server-rust +++ b/aw-server-rust @@ -1 +1 @@ -Subproject commit dc70318e819efc0d0535a5d7bd35a0c7ab8e9106 +Subproject commit 0c8b2adf6dfa39e5d74d05df9466b8e7efd7f171 diff --git a/mobile/src/main/AndroidManifest.xml b/mobile/src/main/AndroidManifest.xml index c983de3a..d4653812 100644 --- a/mobile/src/main/AndroidManifest.xml +++ b/mobile/src/main/AndroidManifest.xml @@ -48,6 +48,12 @@ android:theme="@style/AppTheme.NoActionBar" android:exported="true"/> + + = Build.VERSION_CODES.TIRAMISU) { + clip.description.extras = PersistableBundle().also { + it.putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true) + } + } + clipboard.setPrimaryClip(clip) + Toast.makeText(this, "API key copied to clipboard", Toast.LENGTH_SHORT).show() + } + + btnRegenerate.setOnClickListener { + val newKey = configManager.generateAndSetApiKey() + if (newKey == null) { + refreshUI() + Toast.makeText(this, "Failed to save API key", Toast.LENGTH_LONG).show() + return@setOnClickListener + } + tvApiKey.text = newKey + btnCopy.visibility = View.VISIBLE + // Update switch without triggering its listener (which would show a second toast) + isUpdatingSwitch = true + switchAuthEnabled.isChecked = true + isUpdatingSwitch = false + tvStatus.text = "Authentication enabled (restart app to apply)" + Toast.makeText(this, "New API key generated. Restart app to apply.", Toast.LENGTH_LONG).show() + } + + switchAuthEnabled.setOnCheckedChangeListener { _: CompoundButton, isChecked: Boolean -> + if (isUpdatingSwitch) return@setOnCheckedChangeListener + if (isChecked) { + val current = configManager.readAuthConfig() + if (!current.isEnabled) { + val newKey = configManager.generateAndSetApiKey() + if (newKey == null) { + refreshUI() + Toast.makeText(this, "Failed to save API key", Toast.LENGTH_LONG).show() + return@setOnCheckedChangeListener + } + tvApiKey.text = newKey + } + btnCopy.visibility = View.VISIBLE + tvStatus.text = "Authentication enabled (restart app to apply)" + } else { + if (!configManager.clearApiKey()) { + refreshUI() + Toast.makeText(this, "Failed to save API key setting", Toast.LENGTH_LONG).show() + return@setOnCheckedChangeListener + } + tvApiKey.text = "(none)" + btnCopy.visibility = View.GONE + tvStatus.text = "Authentication disabled (restart app to apply)" + } + Toast.makeText(this, "Setting saved. Restart app to apply.", Toast.LENGTH_SHORT).show() + } + } + + override fun onResume() { + super.onResume() + if (::configManager.isInitialized) { + refreshUI() + } + } + + private fun refreshUI() { + val auth = configManager.readAuthConfig() + if (auth.isEnabled) { + tvApiKey.text = auth.apiKey + tvStatus.text = "Authentication is enabled" + isUpdatingSwitch = true + switchAuthEnabled.isChecked = true + isUpdatingSwitch = false + btnCopy.visibility = View.VISIBLE + } else { + tvApiKey.text = "(none — authentication disabled)" + tvStatus.text = "Authentication is disabled" + isUpdatingSwitch = true + switchAuthEnabled.isChecked = false + isUpdatingSwitch = false + btnCopy.visibility = View.GONE + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + android.R.id.home -> { + onBackPressedDispatcher.onBackPressed() + true + } + else -> super.onOptionsItemSelected(item) + } + } +} diff --git a/mobile/src/main/java/net/activitywatch/android/ConfigManager.kt b/mobile/src/main/java/net/activitywatch/android/ConfigManager.kt new file mode 100644 index 00000000..4ac749c7 --- /dev/null +++ b/mobile/src/main/java/net/activitywatch/android/ConfigManager.kt @@ -0,0 +1,110 @@ +package net.activitywatch.android + +import android.content.Context +import android.util.Log +import java.io.File +import java.io.IOException +import java.util.UUID + +private const val TAG = "ConfigManager" + +/** + * Manages reading and writing the embedded aw-server config.toml stored in + * the app's filesDir. The config format is TOML; we only need the [auth] + * section, so we handle it with simple string operations rather than pulling + * in a full TOML library. + * + * Changes take effect after the server restarts (i.e. app restart). + */ +class ConfigManager(context: Context) { + + private val configFile = File(context.filesDir, "config.toml") + + data class AuthConfig( + val apiKey: String? = null + ) { + val isEnabled: Boolean get() = !apiKey.isNullOrEmpty() + } + + fun readAuthConfig(): AuthConfig { + if (!configFile.exists()) { + Log.d(TAG, "config.toml not found, returning defaults") + return AuthConfig() + } + return try { + val content = configFile.readText() + val apiKey = parseApiKey(content) + AuthConfig(apiKey = apiKey) + } catch (e: Exception) { + Log.e(TAG, "Failed to read config.toml: ${e.message}") + AuthConfig() + } + } + + fun setApiKey(key: String?): Boolean { + return try { + val current = if (configFile.exists()) configFile.readText() else "" + val updated = writeApiKey(current, key) + val tmpFile = File(configFile.parent, configFile.name + ".tmp") + tmpFile.writeText(updated) + if (!tmpFile.renameTo(configFile)) { + tmpFile.delete() + throw IOException("Failed to atomically replace config.toml") + } + Log.d(TAG, "API key updated in config.toml") + true + } catch (e: Exception) { + Log.e(TAG, "Failed to write config.toml: ${e.message}") + false + } + } + + fun generateAndSetApiKey(): String? { + val key = UUID.randomUUID().toString().replace("-", "") + return if (setApiKey(key)) key else null + } + + fun clearApiKey() = setApiKey(null) + + // ---- internal TOML manipulation ---- + + private fun parseApiKey(content: String): String? { + // Find the [auth] section, then look for api_key = "..." + val authSectionRegex = Regex("""(?m)^\[auth\].*?(?=^\[|\z)""", RegexOption.DOT_MATCHES_ALL) + val authSection = authSectionRegex.find(content)?.value ?: return null + val keyMatch = Regex("""api_key\s*=\s*"([^"]*)"""").find(authSection) + val key = keyMatch?.groupValues?.get(1) + return if (key.isNullOrEmpty()) null else key + } + + private fun writeApiKey(content: String, key: String?): String { + val authLine = if (key.isNullOrEmpty()) null else """api_key = "$key"""" + val authSectionRegex = Regex("""(?m)^\[auth\].*?(?=^\[|\z)""", RegexOption.DOT_MATCHES_ALL) + val authSectionMatch = authSectionRegex.find(content) + + if (authSectionMatch != null) { + val authSectionContent = authSectionMatch.value + // Modify only within the [auth] section to avoid touching other sections + val apiKeyLinePresent = Regex("""(?m)^api_key\s*=""").containsMatchIn(authSectionContent) + val updatedSection = if (apiKeyLinePresent) { + val replaced = Regex("""(?m)^api_key\s*=.*$""") + .replaceFirst(authSectionContent, authLine ?: "") + if (authLine == null) replaced.replace(Regex("\n{3,}"), "\n\n") else replaced + } else if (authLine != null) { + val separator = if (authSectionContent.endsWith("\n")) "" else "\n" + "$authSectionContent$separator$authLine\n" + } else { + authSectionContent + } + return content.replaceRange(authSectionMatch.range, updatedSection) + } + + // No [auth] section — append it if we have a key + return if (authLine != null) { + val base = content.trimEnd() + "$base\n\n[auth]\n$authLine\n" + } else { + content + } + } +} diff --git a/mobile/src/main/java/net/activitywatch/android/MainActivity.kt b/mobile/src/main/java/net/activitywatch/android/MainActivity.kt index 0c439e0f..e7bfe017 100644 --- a/mobile/src/main/java/net/activitywatch/android/MainActivity.kt +++ b/mobile/src/main/java/net/activitywatch/android/MainActivity.kt @@ -96,8 +96,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte // as you specify a parent activity in AndroidManifest.xml. return when (item.itemId) { R.id.action_settings -> { - Snackbar.make(binding.coordinatorLayout, "The settings button was clicked, but it's not yet implemented!", Snackbar.LENGTH_LONG) - .setAction("Action", null).show() + startActivity(Intent(this, AuthSettingsActivity::class.java)) true } else -> super.onOptionsItemSelected(item) diff --git a/mobile/src/main/res/layout/activity_auth_settings.xml b/mobile/src/main/res/layout/activity_auth_settings.xml new file mode 100644 index 00000000..6d2d3033 --- /dev/null +++ b/mobile/src/main/res/layout/activity_auth_settings.xml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + +