~eliasnaur/gio

This thread contains a patchset. You're looking at the original emails, but you may wish to use the patch review UI. Review patch
4 3

[PATCH 1/2] app: add RegisterDelegate method on *Window for Android

Greg Pomerantz <gmp.gio@wow.st>
Details
Message ID
<20191120222800.23686-1-gmp.gio@wow.st>
DKIM signature
missing
Download raw message
Patch: +87 -23
RegisterDelegate creates an instance of a Java class and passes
a window's Activity to its constructor.

Signed-off-by: Greg Pomerantz <gmp.gio@wow.st>
---
 app/app.go                           |  7 -----
 app/app_android.go                   | 20 +++++++++++++
 app/internal/window/GioActivity.java |  2 +-
 app/internal/window/GioView.java     | 54 ++++++++++++++++++++++++++++++------
 app/internal/window/handle.go        |  7 -----
 app/internal/window/os_android.c     |  5 ++++
 app/internal/window/os_android.go    | 14 ++++++++++
 app/internal/window/os_android.h     |  1 +
 8 files changed, 87 insertions(+), 23 deletions(-)
 create mode 100644 app/app_android.go
 delete mode 100644 app/internal/window/handle.go

diff --git a/app/app.go b/app/app.go
index 9a18ec4..71bc57b 100644
--- a/app/app.go
+++ b/app/app.go
@@ -9,13 +9,6 @@ import (
	"gioui.org/app/internal/window"
)

type Handle window.Handle

// PlatformHandle returns the platform specific Handle.
func PlatformHandle() *Handle {
	return (*Handle)(window.PlatformHandle)
}

// extraArgs contains extra arguments to append to
// os.Args. The arguments are separated with |.
// Useful for running programs on mobiles where the
diff --git a/app/app_android.go b/app/app_android.go
new file mode 100644
index 0000000..a33c990
--- /dev/null
+++ b/app/app_android.go
@@ -0,0 +1,20 @@
package app

import (
	"gioui.org/app/internal/window"
)

type Handle window.Handle

// PlatformHandle returns the Android platform-specific Handle.
func PlatformHandle() *Handle {
	return (*Handle)(window.PlatformHandle)
}

// RegisterDelegate constructs a Java instance of the specified class
// and calls its public initView(android.app.Activity) constructor with
// the Activity that created the window.
func (w *Window) RegisterDelegate(del string) {
	d := w.driver.(window.AndroidDriver)
	d.RegisterDelegate(del)
}
diff --git a/app/internal/window/GioActivity.java b/app/internal/window/GioActivity.java
index 2813d80..1a91f81 100644
--- a/app/internal/window/GioActivity.java
+++ b/app/internal/window/GioActivity.java
@@ -33,7 +33,7 @@ public class GioActivity extends Activity {

	@Override public void onStart() {
		super.onStart();
		view.start();
		view.start(this);
	}

	@Override public void onStop() {
diff --git a/app/internal/window/GioView.java b/app/internal/window/GioView.java
index 82bc36f..38f6c6e 100644
--- a/app/internal/window/GioView.java
+++ b/app/internal/window/GioView.java
@@ -2,12 +2,18 @@

package org.gioui;

import java.lang.Class;
import java.lang.ClassLoader;
import java.lang.reflect.Constructor;
import java.lang.Throwable;
import android.app.Activity;
import android.content.Context;
import android.graphics.Rect;
import android.os.Build;
import android.os.Handler;
import android.util.AttributeSet;
import android.text.Editable;
import android.util.AttributeSet;
import android.util.Log;
import android.view.Choreographer;
import android.view.KeyCharacterMap;
import android.view.KeyEvent;
@@ -31,7 +37,9 @@ public class GioView extends SurfaceView implements Choreographer.FrameCallback
	private final SurfaceHolder.Callback callbacks;
	private final InputMethodManager imm;
	private final Handler handler;
	private final ClassLoader classloader;
	private long nhandle;
	private Activity activity;

	private static synchronized void initialize(Context appCtx) {
		synchronized (initLock) {
@@ -51,18 +59,19 @@ public class GioView extends SurfaceView implements Choreographer.FrameCallback
		}
	}

	public GioView(Context context) {
		this(context, null);
	public GioView(Activity activity) {
		this(activity, null);
	}

	public GioView(Context context, AttributeSet attrs) {
		super(context, attrs);
	public GioView(Activity activity, AttributeSet attrs) {
		super(activity, attrs);
		// Late initialization of the Go runtime to wait for a valid context.
		initialize(context.getApplicationContext());
		initialize(activity.getApplicationContext());

		classloader = GioView.class.getClassLoader();
		nhandle = onCreateView(this);
		handler = new Handler();
		imm = (InputMethodManager)context.getSystemService(Context.INPUT_METHOD_SERVICE);
		imm = (InputMethodManager)activity.getSystemService(Context.INPUT_METHOD_SERVICE);
		setFocusable(true);
		setFocusableInTouchMode(true);
		setOnFocusChangeListener(new View.OnFocusChangeListener() {
@@ -181,15 +190,18 @@ public class GioView extends SurfaceView implements Choreographer.FrameCallback
		return getResources().getConfiguration().fontScale;
	}

	void start() {
	void start(Activity act) {
		this.activity = act;
		onStartView(nhandle);
	}

	void stop() {
		this.activity = null;
		onStopView(nhandle);
	}

	void destroy() {
		this.activity = null;
		getHolder().removeCallback(callbacks);
		onDestroyView(nhandle);
		nhandle = 0;
@@ -207,6 +219,32 @@ public class GioView extends SurfaceView implements Choreographer.FrameCallback
		return onBack(nhandle);
	}

	public void RegisterDelegate(String del) {
		handler.post(new Runnable() {
			public void run() {
				Class cls;
				try {
					cls = classloader.loadClass(del);
				} catch (ClassNotFoundException ignored) {
					Log.e("gio", "RegisterDelegate: Class " + del + "not found");
					return;
				}
				Constructor init;
				try {
					init = cls.getConstructor(Activity.class);
				} catch (NoSuchMethodException ignored) {
					Log.e("gio", "RegisterDelegate: constructor not found for " + del);
					return;
				}
				try {
					init.newInstance(activity);
				} catch (Throwable e) {
					Log.e("gio", "RegisterDelegate: constructor failed: " + e.getMessage());
				}
			}
		});
	}

	static private native long onCreateView(GioView view);
	static private native void onDestroyView(long handle);
	static private native void onStartView(long handle);
diff --git a/app/internal/window/handle.go b/app/internal/window/handle.go
deleted file mode 100644
index 5d7b1a1..0000000
--- a/app/internal/window/handle.go
@@ -1,7 +0,0 @@
// +build !android

package window

var PlatformHandle *Handle

type Handle struct{}
diff --git a/app/internal/window/os_android.c b/app/internal/window/os_android.c
index 5150ab6..59d0da2 100644
--- a/app/internal/window/os_android.c
+++ b/app/internal/window/os_android.c
@@ -166,3 +166,8 @@ void gio_jni_ReleaseByteArrayElements(JNIEnv *env, jbyteArray arr, jbyte *bytes)
jsize gio_jni_GetArrayLength(JNIEnv *env, jbyteArray arr) {
	return (*env)->GetArrayLength(env, arr);
}

void gio_jni_RegisterDelegate(JNIEnv *env, jobject view, jmethodID mid, char* del) {
	jstring jdel = (*env)->NewStringUTF(env, del);
	(*env)->CallVoidMethod(env, view, mid, jdel);
}
diff --git a/app/internal/window/os_android.go b/app/internal/window/os_android.go
index 5cce88d..7ace02d 100644
--- a/app/internal/window/os_android.go
+++ b/app/internal/window/os_android.go
@@ -54,6 +54,7 @@ type window struct {
	mhideTextInput                 C.jmethodID
	mpostFrameCallback             C.jmethodID
	mpostFrameCallbackOnMainThread C.jmethodID
	mRegisterDelegate              C.jmethodID
}

var dataDirChan = make(chan string, 1)
@@ -119,6 +120,7 @@ func onCreateView(env *C.JNIEnv, class C.jclass, view C.jobject) C.jlong {
		mhideTextInput:                 jniGetMethodID(env, class, "hideTextInput", "()V"),
		mpostFrameCallback:             jniGetMethodID(env, class, "postFrameCallback", "()V"),
		mpostFrameCallbackOnMainThread: jniGetMethodID(env, class, "postFrameCallbackOnMainThread", "()V"),
		mRegisterDelegate:              jniGetMethodID(env, class, "RegisterDelegate", "(Ljava/lang/String;)V"),
	}
	wopts := <-mainWindow.out
	w.callbacks = wopts.window
@@ -443,6 +445,18 @@ func (w *window) ShowTextInput(show bool) {
	})
}

type AndroidDriver interface {
	RegisterDelegate(string)
}

func (w *window) RegisterDelegate(del string) {
	runInJVM(func(env *C.JNIEnv) {
		cdel := C.CString(del)
		defer C.free(unsafe.Pointer(cdel))
		C.gio_jni_RegisterDelegate(env, w.view, w.mRegisterDelegate, cdel)
	})
}

func Main() {
}

diff --git a/app/internal/window/os_android.h b/app/internal/window/os_android.h
index 288d633..8f9c101 100644
--- a/app/internal/window/os_android.h
+++ b/app/internal/window/os_android.h
@@ -17,3 +17,4 @@ __attribute__ ((visibility ("hidden"))) void gio_jni_CallVoidMethod_J(JNIEnv *en
__attribute__ ((visibility ("hidden"))) jbyte *gio_jni_GetByteArrayElements(JNIEnv *env, jbyteArray arr);
__attribute__ ((visibility ("hidden"))) void gio_jni_ReleaseByteArrayElements(JNIEnv *env, jbyteArray arr, jbyte *bytes);
__attribute__ ((visibility ("hidden"))) jsize gio_jni_GetArrayLength(JNIEnv *env, jbyteArray arr);
void gio_jni_RegisterDelegate(JNIEnv *env, jobject view, jmethodID mid, char* del);
-- 
2.16.2

[PATCH 2/2] app/permission: add storage permissions

Greg Pomerantz <gmp.gio@wow.st>
Details
Message ID
<20191120222800.23686-2-gmp.gio@wow.st>
In-Reply-To
<20191120222800.23686-1-gmp.gio@wow.st> (view parent)
DKIM signature
missing
Download raw message
Patch: +5 -0
Storage permissions enables the Android permissions
READ_EXTERNAL_STORAGE and WRITE_EXTERNAL_STORAGE.
---
 app/permission/storage/main.go | 1 +
 cmd/gogio/permission.go        | 4 ++++
 2 files changed, 5 insertions(+)
 create mode 100644 app/permission/storage/main.go

diff --git a/app/permission/storage/main.go b/app/permission/storage/main.go
new file mode 100644
index 0000000..82be054
--- /dev/null
+++ b/app/permission/storage/main.go
@@ -0,0 +1 @@
package storage
diff --git a/cmd/gogio/permission.go b/cmd/gogio/permission.go
index eabbb91..a39f1b8 100644
--- a/cmd/gogio/permission.go
+++ b/cmd/gogio/permission.go
@@ -14,6 +14,10 @@ var AndroidPermissions = map[string][]string{
		"android.permission.BLUETOOTH_ADMIN",
		"android.permission.ACCESS_FINE_LOCATION",
	},
	"storage": {
		"android.permission.READ_EXTERNAL_STORAGE",
		"android.permission.WRITE_EXTERNAL_STORAGE",
	},
}

var AndroidFeatures = map[string][]string{
-- 
2.16.2
Details
Message ID
<BYL3UHHK48M4.3MSIORPE21VMT@toolbox>
In-Reply-To
<20191120222800.23686-1-gmp.gio@wow.st> (view parent)
DKIM signature
missing
Download raw message
Thank you, this is looking quite clean.

On Wed Nov 20, 2019 at 5:27 PM Greg Pomerantz wrote:
> RegisterDelegate creates an instance of a Java class and passes
> a window's Activity to its constructor.
> 
> Signed-off-by: Greg Pomerantz <gmp.gio@wow.st>
> ---
>  app/app.go                           |  7 -----
>  app/app_android.go                   | 20 +++++++++++++
>  app/internal/window/GioActivity.java |  2 +-
>  app/internal/window/GioView.java     | 54 ++++++++++++++++++++++++++++++------
>  app/internal/window/handle.go        |  7 -----
>  app/internal/window/os_android.c     |  5 ++++
>  app/internal/window/os_android.go    | 14 ++++++++++
>  app/internal/window/os_android.h     |  1 +
>  8 files changed, 87 insertions(+), 23 deletions(-)
>  create mode 100644 app/app_android.go
>  delete mode 100644 app/internal/window/handle.go
> 
> diff --git a/app/app.go b/app/app.go
> index 9a18ec4..71bc57b 100644
> --- a/app/app.go
> +++ b/app/app.go
> @@ -9,13 +9,6 @@ import (
>  	"gioui.org/app/internal/window"
>  )
>  
> -type Handle window.Handle
> -
> -// PlatformHandle returns the platform specific Handle.
> -func PlatformHandle() *Handle {
> -	return (*Handle)(window.PlatformHandle)
> -}
> -
>  // extraArgs contains extra arguments to append to
>  // os.Args. The arguments are separated with |.
>  // Useful for running programs on mobiles where the
> diff --git a/app/app_android.go b/app/app_android.go
> new file mode 100644
> index 0000000..a33c990
> --- /dev/null
> +++ b/app/app_android.go
> @@ -0,0 +1,20 @@
> +package app
> +
> +import (
> +	"gioui.org/app/internal/window"
> +)
> +
> +type Handle window.Handle
> +
> +// PlatformHandle returns the Android platform-specific Handle.
> +func PlatformHandle() *Handle {
> +	return (*Handle)(window.PlatformHandle)
> +}

Do you need it? If so, make it RegisterDelegate. If not, leave it out for now,
including the app/internal/window support types.

> diff --git a/app/internal/window/GioView.java b/app/internal/window/GioView.java
> index 82bc36f..38f6c6e 100644
> --- a/app/internal/window/GioView.java
> +++ b/app/internal/window/GioView.java
> @@ -51,18 +59,19 @@ public class GioView extends SurfaceView implements Choreographer.FrameCallback
u>  		}
>  	}
>  
> -	public GioView(Context context) {
> -		this(context, null);
> +	public GioView(Activity activity) {
> +		this(activity, null);
>  	}
>  
> -	public GioView(Context context, AttributeSet attrs) {
> -		super(context, attrs);
> +	public GioView(Activity activity, AttributeSet attrs) {
> +		super(activity, attrs);
>  		// Late initialization of the Go runtime to wait for a valid context.
> -		initialize(context.getApplicationContext());
> +		initialize(activity.getApplicationContext());
>  
> +		classloader = GioView.class.getClassLoader();

No need for caching the class loader. Fetch it in registerDelegate.

> @@ -181,15 +190,18 @@ public class GioView extends SurfaceView implements Choreographer.FrameCallback
>  		return getResources().getConfiguration().fontScale;
>  	}
>  
> -	void start() {
> +	void start(Activity act) {
> +		this.activity = act;
>  		onStartView(nhandle);
>  	}
>  
>  	void stop() {
> +		this.activity = null;

Don't nil the activity here. A stopped Activity might still be useful to delegates.

>  		onStopView(nhandle);
>  	}
>  
>  	void destroy() {
> +		this.activity = null;
>  		getHolder().removeCallback(callbacks);
>  		onDestroyView(nhandle);
>  		nhandle = 0;
> @@ -207,6 +219,32 @@ public class GioView extends SurfaceView implements Choreographer.FrameCallback
>  		return onBack(nhandle);
>  	}
>  
> +	public void RegisterDelegate(String del) {

Lower case "registerDelegate".

> +		handler.post(new Runnable() {
> +			public void run() {
> +				Class cls;
> +				try {
> +					cls = classloader.loadClass(del);
> +				} catch (ClassNotFoundException ignored) {

Throw a RuntimeException(e) here, and catch it in RegisterDelegate, turning it into a Go error.

> +					Log.e("gio", "RegisterDelegate: Class " + del + "not found");
> +					return;
> +				}
> +				Constructor init;
> +				try {
> +					init = cls.getConstructor(Activity.class);

I still think the argument type should just be android.content.Context, and let be up to
the delegate to cast GioView's getContext to Activity. That way we have more wiggle room
when allowing embedding of Gio windows into existing Android apps.

If you can somehow guarantee that View.getContext is alway of type Activity, by
all means keep the signature but use (Activity)getContext() instead of manually
tracking it.

Also, make init/onRegister/... a method and create the instance with
Class.newInstance. Constructors don't have a name for signifying the meaning of
the method.

Finally, should we return an error to RegisterDelegate if getContext is null?
That gives the caller a chance to retry.

> +				} catch (NoSuchMethodException ignored) {
> +					Log.e("gio", "RegisterDelegate: constructor not found for " + del);

Same.

> +					return;
> +				}
> +				try {
> +					init.newInstance(activity);
> +				} catch (Throwable e) {
> +					Log.e("gio", "RegisterDelegate: constructor failed: " + e.getMessage());

Same.

> +				}
> +			}
> +		});

Note that there can be many GioView/GioActivity for the single Gio Window. gogio's AndroidManifest.xml lists

              android:configChanges="orientation|keyboardHidden"

to avoid re-creating the GioActivity for screen rotation and keyboard changes, but Android might re-create the
activity for other reasons. If you remove configChanges manually, you can test Activity re-creation by rotating
your device.

In other words, your delegate needs to register its tracking Fragment with setRetainInstance(true). You may
want to note that in the RegisterDelegate docs, perhaps with a snippet of code to show how.

> diff --git a/app/internal/window/os_android.c b/app/internal/window/os_android.c
> index 5150ab6..59d0da2 100644
> --- a/app/internal/window/os_android.c
> +++ b/app/internal/window/os_android.c
> @@ -166,3 +166,8 @@ void gio_jni_ReleaseByteArrayElements(JNIEnv *env, jbyteArray arr, jbyte *bytes)
>  jsize gio_jni_GetArrayLength(JNIEnv *env, jbyteArray arr) {
>  	return (*env)->GetArrayLength(env, arr);
>  }
> +
> +void gio_jni_RegisterDelegate(JNIEnv *env, jobject view, jmethodID mid, char* del) {
> +	jstring jdel = (*env)->NewStringUTF(env, del);
> +	(*env)->CallVoidMethod(env, view, mid, jdel);
> +}
> diff --git a/app/internal/window/os_android.go b/app/internal/window/os_android.go
> index 5cce88d..7ace02d 100644
> --- a/app/internal/window/os_android.go
> +++ b/app/internal/window/os_android.go
> @@ -443,6 +445,18 @@ func (w *window) ShowTextInput(show bool) {
>  	})
>  }
>  
> +type AndroidDriver interface {
> +	RegisterDelegate(string)
> +}
> +
> +func (w *window) RegisterDelegate(del string) {
> +	runInJVM(func(env *C.JNIEnv) {
> +		cdel := C.CString(del)
> +		defer C.free(unsafe.Pointer(cdel))
> +		C.gio_jni_RegisterDelegate(env, w.view, w.mRegisterDelegate, cdel)

Check that w.view is set. If not, return error.

> +	})
> +}
> +
>  func Main() {
>  }
>  
> diff --git a/app/internal/window/os_android.h b/app/internal/window/os_android.h
> index 288d633..8f9c101 100644
> --- a/app/internal/window/os_android.h
> +++ b/app/internal/window/os_android.h
> @@ -17,3 +17,4 @@ __attribute__ ((visibility ("hidden"))) void gio_jni_CallVoidMethod_J(JNIEnv *en
>  __attribute__ ((visibility ("hidden"))) jbyte *gio_jni_GetByteArrayElements(JNIEnv *env, jbyteArray arr);
>  __attribute__ ((visibility ("hidden"))) void gio_jni_ReleaseByteArrayElements(JNIEnv *env, jbyteArray arr, jbyte *bytes);
>  __attribute__ ((visibility ("hidden"))) jsize gio_jni_GetArrayLength(JNIEnv *env, jbyteArray arr);
> +void gio_jni_RegisterDelegate(JNIEnv *env, jobject view, jmethodID mid, char* del);

Add the visibility attribute.

Re: [PATCH 2/2] app/permission: add storage permissions

Details
Message ID
<BYL36X52IDLY.2HQN3Q8ZWO9W0@toolbox>
In-Reply-To
<20191120222800.23686-2-gmp.gio@wow.st> (view parent)
DKIM signature
missing
Download raw message
On Wed Nov 20, 2019 at 5:28 PM Greg Pomerantz wrote:
> Storage permissions enables the Android permissions
> READ_EXTERNAL_STORAGE and WRITE_EXTERNAL_STORAGE.
> ---
>  app/permission/storage/main.go | 1 +
>  cmd/gogio/permission.go        | 4 ++++
>  2 files changed, 5 insertions(+)
>  create mode 100644 app/permission/storage/main.go
> 
> diff --git a/app/permission/storage/main.go b/app/permission/storage/main.go
> new file mode 100644
> index 0000000..82be054
> --- /dev/null
> +++ b/app/permission/storage/main.go
> @@ -0,0 +1 @@
> +package storage

Add package documentation.
Gregory Pomerantz <gmp.gio@wow.st>
Details
Message ID
<6ebcf956-179f-86e9-88a8-f6def6b48ef3@wow.st>
In-Reply-To
<BYL3UHHK48M4.3MSIORPE21VMT@toolbox> (view parent)
DKIM signature
missing
Download raw message
On 11/20/19 6:15 PM, Elias Naur wrote:

>> +type Handle window.Handle
>> +
>> +// PlatformHandle returns the Android platform-specific Handle.
>> +func PlatformHandle() *Handle {
>> +	return (*Handle)(window.PlatformHandle)
>> +}
> Do you need it? If so, make it RegisterDelegate. If not, leave it out for now,
> including the app/internal/window support types.


For JNI-only apps that do not want to register a java class, and so we 
can call RegisterNatives to install our callback that triggers once the 
Delegate is installed (we can use the default ugly names convention 
here, so this is perhaps not requred).


>> -	void start() {
>> +	void start(Activity act) {
>> +		this.activity = act;
>>   		onStartView(nhandle);
>>   	}
>>   
>>   	void stop() {
>> +		this.activity = null;
> Don't nil the activity here. A stopped Activity might still be useful to delegates.


I thought a stopped (as opposed to paused) activity can only progress to 
destroy or be directly killed by the OS, so I wasn't sure anything can 
be done here. That said if we are passing Context instead of Activity 
(and leaving it up to the user to cast it) we don't need these at all 
and can just store Context when we get it in the constructor.


>> +		handler.post(new Runnable() {
>> +			public void run() {
>> +				Class cls;
>> +				try {
>> +					cls = classloader.loadClass(del);
>> +				} catch (ClassNotFoundException ignored) {
> Throw a RuntimeException(e) here, and catch it in RegisterDelegate, turning it into a Go error.


Can we get exceptions out of the handler? We can return a string from 
registerDelegate() with the error (or "" on success) and turn it into a 
Go error but this will make registerDelegate a blocking call. As I am 
using this so far, I am depending on the installed delegate to call back 
to Go to register success -- if it fails to do so, the library can log 
an error after a timeout. That said, if we know for sure that 
registerDelegate will fail (e.g. getContext is nil?) we can return an error.


> I still think the argument type should just be android.content.Context, and let be up to
> the delegate to cast GioView's getContext to Activity. That way we have more wiggle room
> when allowing embedding of Gio windows into existing Android apps.
>
> If you can somehow guarantee that View.getContext is alway of type Activity, by
> all means keep the signature but use (Activity)getContext() instead of manually
> tracking it.
>
> Also, make init/onRegister/... a method and create the instance with
> Class.newInstance. Constructors don't have a name for signifying the meaning of
> the method.


Ok. One thought: if we want to explicitly support Fragments, then we can 
detect if the object extends Fragment and attach it to the view's 
Context automatically. The onAttach method of Fragment is called with 
the Context, which will be an instanceof Activity if the Fragment is 
attached to an Activity. If it is not a Fragment we manually call 
onRegister() with the Context. I think Fragments will be able to restart 
themselves automatically this way without any intervention from Gio. I 
suppose we can leave it up to the devs to manually re-register other 
classes (and document this behavior)?


> Finally, should we return an error to RegisterDelegate if getContext is null?
> That gives the caller a chance to retry.


That sounds like a good idea.


> Note that there can be many GioView/GioActivity for the single Gio Window. gogio's AndroidManifest.xml lists
>
>                android:configChanges="orientation|keyboardHidden"
>
> to avoid re-creating the GioActivity for screen rotation and keyboard changes, but Android might re-create the
> activity for other reasons. If you remove configChanges manually, you can test Activity re-creation by rotating
> your device.
>
> In other words, your delegate needs to register its tracking Fragment with setRetainInstance(true). You may
> want to note that in the RegisterDelegate docs, perhaps with a snippet of code to show how.


That looks like a super-useful test.
Reply to thread Export thread (mbox)