Hacker News new | ask | show | jobs
by CyberShadow 57 days ago
Same, I've added a .#screenshots derivation. High up-front effort but almost zero maintenance afterwards.

Bonus: since you're generating screenshots programmatically anyway, you can generate a pair of each with your app's light/dark theme, and swap them in/out depending on prefers-color-scheme: dark. <picture> elements work in GitHub READMEs, too: https://github.com/CyberShadow/CyDo#readme

2 comments

+1 for this approach. For a mobile app, I made Nix spawn an ephemeral Android emulator instance for generating up-to-date screenshots, requiring no prior setup and leaving no lingering data around after running. Setting it up wasn't that high-effort in my case either; coming up with the idea was the hard part, the Nix code was one-shot by your favorite LLM.

Granted manually updating the screenshots isn't the most laborious task in the world, but the "upload-apk + take-screenshot + transfer-back-to-PC + edit" process is usually barely annoying enough that you end up almost never doing it otherwise (similar to the OP's experience in the closing paragraph).

That sounds so cool! Is the repo available anywhere?
Nothing public yet, but this is the Nix output for taking the screenshot, to be executed via `nix run .#screenshot`:

        outputs.apps.x86_64.screenshot = {
          type = "app";
          program = toString (pkgs.writeShellScript "screenshot-script" ''
            set -euo pipefail

            EMU_SDK="${androidEmulatorComposition.androidsdk}/libexec/android-sdk"
            ADB="$EMU_SDK/platform-tools/adb"
            EMULATOR="$EMU_SDK/emulator/emulator"
            APK="${self.packages.${system}.debug}/myapp-debug.apk"

            SRC_DIR="$(${pkgs.git}/bin/git rev-parse --show-toplevel)"
            AVD_HOME="$(mktemp -d)"
            trap 'kill "$EMU_PID" 2>/dev/null; wait "$EMU_PID" 2>/dev/null; rm -rf "$AVD_HOME"' EXIT

            # Create AVD
            AVD_DIR="$AVD_HOME/screenshot.avd"
            mkdir -p "$AVD_DIR"
            cat > "$AVD_HOME/screenshot.ini" <<EOF
            avd.ini.encoding=UTF-8
            path=$AVD_DIR
            target=android-${platformVersion}
            EOF
            cat > "$AVD_DIR/config.ini" <<EOF
            AvdId=screenshot
            PlayStore.enabled=false
            abi.type=x86_64
            avd.ini.encoding=UTF-8
            hw.cpu.arch=x86_64
            hw.gpu.enabled=yes
            hw.gpu.mode=swiftshader_indirect
            hw.lcd.density=420
            hw.lcd.height=2400
            hw.lcd.width=1080
            hw.ramSize=2048
            image.sysdir.1=system-images/android-${platformVersion}/google_apis/x86_64/
            skin.dynamic=yes
            tag.display=Google APIs
            tag.id=google_apis
            disk.dataPartition.size=2G
            EOF

            echo "==> Starting emulator..."
            ANDROID_AVD_HOME="$AVD_HOME" ANDROID_HOME="$EMU_SDK" \
              "$EMULATOR" -avd screenshot -no-window -no-audio -no-boot-anim \
              -gpu swiftshader_indirect -no-snapshot 2>&1 &
            EMU_PID=$!

            echo "==> Waiting for boot..."
            for i in $(seq 1 90); do
              BOOT=$("$ADB" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r') || true
              if [ "$BOOT" = "1" ]; then
                echo "    Booted after ~$((i * 2))s"
                break
              fi
              sleep 2
            done
            if [ "$BOOT" != "1" ]; then
              echo "ERROR: Emulator failed to boot" >&2
              exit 1
            fi

            # Enable dark mode
            "$ADB" shell cmd uimode night yes

            # Install and launch
            echo "==> Installing APK..."
            "$ADB" install -r "$APK"
            "$ADB" shell pm grant com.me.myapp android.permission.WRITE_SECURE_SETTINGS
            "$ADB" shell am start -n com.me.myapp/.MainActivity
            sleep 3

            # Navigate to settings screen by tapping "Notification Filters" button
            # This uses uiautomator to find the button by text for robustness
            "$ADB" shell uiautomator dump /sdcard/ui.xml
            BOUNDS=$("$ADB" shell cat /sdcard/ui.xml \
              | ${pkgs.gnugrep}/bin/grep -oP 'text="Notification Filters"[^>]*bounds="\K[^"]+' \
              || true)
            if [ -z "$BOUNDS" ]; then
              echo "ERROR: Could not find Notification Filters button" >&2
              exit 1
            fi
            # Parse bounds "[x1,y1][x2,y2]" to compute center tap coordinates
            X1=$(echo "$BOUNDS" | ${pkgs.gnused}/bin/sed 's/\[\([0-9]*\),\([0-9]*\)\]\[\([0-9]*\),\([0-9]*\)\]/\1/')
            Y1=$(echo "$BOUNDS" | ${pkgs.gnused}/bin/sed 's/\[\([0-9]*\),\([0-9]*\)\]\[\([0-9]*\),\([0-9]*\)\]/\2/')
            X2=$(echo "$BOUNDS" | ${pkgs.gnused}/bin/sed 's/\[\([0-9]*\),\([0-9]*\)\]\[\([0-9]*\),\([0-9]*\)\]/\3/')
            Y2=$(echo "$BOUNDS" | ${pkgs.gnused}/bin/sed 's/\[\([0-9]*\),\([0-9]*\)\]\[\([0-9]*\),\([0-9]*\)\]/\4/')
            TAP_X=$(( (X1 + X2) / 2 ))
            TAP_Y=$(( (Y1 + Y2) / 2 ))
            "$ADB" shell input tap "$TAP_X" "$TAP_Y"
            sleep 2

            # Capture and process screenshot
            echo "==> Capturing screenshot..."
            "$ADB" shell screencap -p /sdcard/screenshot.png
            "$ADB" pull /sdcard/screenshot.png "$AVD_HOME/raw.png"

            # Crop to content: remove status bar (top 128px) and empty space below
            # Per-App Overrides, then resize with high-quality Lanczos filter
            ${pkgs.imagemagick}/bin/magick "$AVD_HOME/raw.png" \
              -crop 1080x1100+0+128 +repage \
              -filter Lanczos -resize 540x \
              "$SRC_DIR/fastlane/metadata/android/en-US/images/phoneScreenshots/settings.png"

            echo "==> Screenshot saved to fastlane/metadata/android/en-US/images/phoneScreenshots/settings.png"
          '');
        };
The <picture> in README trick works like magic. Thank you! I'm going to steal it.