Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions .github/ISSUE_TEMPLATE/app-bug.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,23 @@ body:
id: exporter
attributes:
label: Which exporter made the zip?
description: >-
Only if this is about importing or about missing locations. The ⋮ menu →
**Information** prints it under the version, and the app's error page prints it too —
copy it from either.
description: |
Only if this is about importing or about missing locations.

**It is per tag, not per app** — you can have tags from several exports, and from your
Apple account, all in one list. So open the tags this is about, one at a time, and read
the **Exported with** row on each. Three things you might see:

- `OpenTagViewer.wizard:1.3.0`, or `.cli`, or `.android` — that is the answer, paste it
- **Not recorded — an older exporter** — the export predates the app writing this down,
which is itself useful: it dates the bundle
- **no such row at all**, and Source says *Read from your Apple account* — that tag never
came from a file, so no exporter was involved and there is nothing to paste

**Do not open the zip to find out.** It holds the keys to your tags, newer ones are
password-protected on purpose, and unpacking it to read a version line is not worth the
risk of what you might then attach.
placeholder: "OpenTagViewer.wizard:1.3.0 — or 1.3.0, or whenever I downloaded it"
placeholder: "Bike: OpenTagViewer.wizard:1.3.0, Keys: from my Apple account"

- type: textarea
id: log
Expand Down
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,16 @@ So releasing is two steps, in this order:
1. Commit the `VERSION` bump to `main`
2. Tag that commit `exporter-v<the same version>` and publish the release

**And the app's release goes out before the exporter's, whenever the exporter's changes what a
bundle is.** They are separate releases with separate tags, which makes them look independent;
they are not. Exporter 1.4.0 locks bundles by default, and an app older than 1.1.0 cannot decrypt
one at all — it fails with a message about the zip rather than about a code. Publish the exporter
first and every bundle written that day is unopenable by whoever receives it, and the recipient is
the one person in that transaction who chose none of it and can fix none of it.

Nothing enforces this — `release_version.py` checks a tag against a version, not one release
against another — so it is a thing to remember, which is why it is written here.

`scripts/release_version.py --kind exporter --tag <tag>` enforces it, and runs in `test-release-version`
before any build job. A tag that disagrees fails the release rather than shipping a build that
lies about itself. `macos-exporter-v` is the old spelling and still resolves, because tags already
Expand Down
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,8 @@ android {
applicationId = "dev.wander.android.opentagviewer"
minSdk = 24
targetSdk = 35
versionCode = 3
versionName = "1.0.5"
versionCode = 4
versionName = "1.1.0"

// Null unless a build type sets it - see the debug block. A release is built from a tag
// and its versionName is exactly right, so there is nothing a commit would add; the
Expand Down
131 changes: 119 additions & 12 deletions app/src/androidTest/java/dev/wander/android/opentagviewer/Shot.java
Original file line number Diff line number Diff line change
@@ -1,21 +1,36 @@
package dev.wander.android.opentagviewer;

import android.app.Activity;
import android.app.Dialog;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.util.Log;
import android.view.View;

import androidx.test.platform.app.InstrumentationRegistry;

import com.google.android.material.color.MaterialColors;

import java.io.File;
import java.io.FileOutputStream;

/**
* A picture of whatever is on the screen, dialogs included.
* A picture of what is on screen, for looking at afterwards.
*
* <p><b>{@link #ofTheScreen} needs a device with a display, and says so rather than lying.</b>
* {@code UiAutomation.takeScreenshot()} photographs the compositor's output, which is the only
* way to capture an activity and a dialog together - and on the headless managed device it
* returns a <i>completely black bitmap</i>, without failing and without warning. Five screens
* were captured that way and every one was black; nothing in the run said so, and the pictures
* were believed to be evidence until somebody opened them.
*
* <p>So it now checks. A frame that came back entirely one colour is not written at all: a
* missing file gets noticed, a black one gets mistaken for a screen that renders nothing.
*
* <p><b>Whole-screen, unlike the {@code view.draw(canvas)} pattern used elsewhere.</b> That one
* needs the view to be in the activity's own hierarchy, and a dialog is not - it lives in its
* own window, so drawing the activity produces the page behind it with a hole where the dialog
* should be. {@code UiAutomation} photographs the compositor's output instead, which is what a
* person actually sees.
* <p>{@link #of(Activity, String)} and {@link #of(Dialog, String)} draw a window's view hierarchy
* instead, which works on both kinds of device - and is what every other screenshot test here
* does. The catch is that a window is all they can draw: an activity drawn while a dialog is up
* is the page behind it, with a hole where the dialog should be.
*
* <p>Writes into the directory AGP passes as {@code additionalTestOutputDir} and does nothing
* when there isn't one, so it is free in an ordinary run. Names are
Expand All @@ -31,9 +46,9 @@ private Shot() {}

private static final String TAG = "Shot";

/** Everything the compositor is showing, dialogs included. Needs a display. */
public static void ofTheScreen(final String name) {
final String dir = InstrumentationRegistry.getArguments()
.getString("additionalTestOutputDir");
final String dir = outputDir();
if (dir == null) {
return;
}
Expand All @@ -45,14 +60,106 @@ public static void ofTheScreen(final String name) {
Log.w(TAG, "the screen could not be photographed for " + name);
return;
}
try (FileOutputStream out =
new FileOutputStream(new File(new File(dir), name + ".png"))) {
bitmap.compress(Bitmap.CompressFormat.PNG, 100, out);

if (isBlank(bitmap)) {
// The headless case. Writing it would produce a black PNG that looks like a
// rendering bug in the app rather than an absent display.
Log.w(TAG, "the screenshot for " + name + " came back blank, which means this"
+ " device has no display - use Shot.of(activity/dialog) instead");
bitmap.recycle();
return;
}
bitmap.recycle();

write(bitmap, dir, name);
} catch (final Exception e) {
// A screenshot explains a failure; it is never the reason for one.
Log.w(TAG, "could not write " + name, e);
}
}

/** The activity's own window, drawn rather than photographed. Works headless. */
public static void of(final Activity activity, final String name) {
draw(activity.getWindow().getDecorView(), name);
}

/**
* A dialog's window, which is a separate one from the activity's.
*
* <p>Drawing the activity while a dialog is up gives the page behind it and a hole where the
* dialog should be - they are different windows, and on a device with no compositor there is
* nothing to put them back together.
*/
public static void of(final Dialog dialog, final String name) {
if (dialog.getWindow() == null) {
Log.w(TAG, "the dialog has no window yet, so nothing was drawn for " + name);
return;
}
draw(dialog.getWindow().getDecorView(), name);
}

private static void draw(final View view, final String name) {
final String dir = outputDir();
if (dir == null) {
return;
}

InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> {
try {
if (view.getWidth() == 0 || view.getHeight() == 0) {
// Not laid out yet, and a 0x0 bitmap throws. Said out loud rather than
// skipped quietly, for the same reason the blank check exists.
Log.w(TAG, "nothing to draw for " + name + " - the view measured 0x0");
return;
}

final Bitmap bitmap = Bitmap.createBitmap(
view.getWidth(), view.getHeight(), Bitmap.Config.ARGB_8888);
final Canvas canvas = new Canvas(bitmap);

// Several layouts here have no background of their own, and a transparent pixel
// flattened into a PNG reads as black - the very thing this class exists to stop
// being mistaken for a real screen.
canvas.drawColor(MaterialColors.getColor(
view, com.google.android.material.R.attr.colorSurface));
view.draw(canvas);

write(bitmap, dir, name);
} catch (final Exception e) {
Log.w(TAG, "could not write " + name, e);
}
});
}

/**
* Whether every pixel is the same colour, sampled on a grid.
*
* <p>Sampled rather than exhaustive: a 1080x2400 frame is 2.6 million pixels and this runs
* between steps of a UI test. A grid catches the case that matters - a frame that is entirely
* one colour - and a real screen differs somewhere within a few samples.
*/
private static boolean isBlank(final Bitmap bitmap) {
final int first = bitmap.getPixel(0, 0);

for (int x = 0; x < bitmap.getWidth(); x += Math.max(1, bitmap.getWidth() / 32)) {
for (int y = 0; y < bitmap.getHeight(); y += Math.max(1, bitmap.getHeight() / 32)) {
if (bitmap.getPixel(x, y) != first) {
return false;
}
}
}

return true;
}

private static String outputDir() {
return InstrumentationRegistry.getArguments().getString("additionalTestOutputDir");
}

private static void write(final Bitmap bitmap, final String dir, final String name)
throws java.io.IOException {
try (FileOutputStream out = new FileOutputStream(new File(new File(dir), name + ".png"))) {
bitmap.compress(Bitmap.CompressFormat.PNG, 100, out);
}
bitmap.recycle();
}
}

This file was deleted.

Loading
Loading