; Begin integrating facilities for executing ERT tests on Android

* test/infra/android/README:

* test/infra/android/bin/AtsStub.java (AtsStub):

* test/infra/android/bin/README:

* test/infra/android/test-controller.el (tramp)
(ats-adb-executable, ats-adb-host, ats-adb-infile, ats-cache)
(ats-adb-disable-stderr, ats-adb-device-regexp, ats-adb)
(ats-adb-process-filter, ats-start-adb, ats-enumerate-devices)
(ats-online-devices, ats-memoize, ats-ps-device, ats-getprop)
(ats-get-sdk-version, ats-package-list-regexp)
(ats-is-package-debuggable, ats-list-users, ats-get-package-aid)
(ats-aid-user-offset, ats-aid-isolated-start, ats-aid-app-start)
(ats-aid-to-uid, ats-uid-to-username, ats-verify-directory)
(ats-get-package-data-directory)
(ats-get-user-external-storage-directory, ats-transfer-padding)
(ats-exec-script, ats-exec-script-checked)
(ats-use-private-staging-directory, ats-get-staging-directory)
(ats-base64-available, ats-echo-n-e, ats-echo-c, ats-octab, c)
(ats-upload-encode-binary, ats-upload, ats-download)
(ats-create-empty-temporary, ats-run-jar)
(ats-supports-am-force-stop, ats-supports-am-force-stop-user)
(ats-kill-process-by-username-and-name)
(ats-portforward-local-type-regexp)
(ats-portforward-remote-type-regexp, ats-portforward-list-regexp)
(ats-portreverse-type-regexp, ats-portreverse-list-regexp)
(ats-reverse-list, ats-reverse-tcp, ats-forward-list)
(ats-forward-tcp, ats-is-tail-available, ats-java-int-min)
(ats-java-int-max, ats-java-long-min, ats-java-long-max)
(ats-intent-array-type, ats-fmt-array-element, ats-build-intent)
(ats-working-stub-file, ats-file-directory, ats-am-start-intent)
(ats-create-commfile, ats-watch-commfile, ats-server)
(ats-default-port, ats-accepting-connection)
(ats-address-to-hostname, ats-is-localhost-p)
(ats-server-sentinel, ats-server-log, ats-server-exists-p)
(ats-start-server, ats-await-connection-timeout)
(ats-await-connection, ats-forward-server-sentinel)
(ats-forward-server-filter, ats-reverse-server)
(ats-forward-server, ats-cancel-forward-server, ats-remote-port)
(ats-in-connection-context, ats-outstanding-reverse-connection)
(ats-terminate-reverse-safely, ats-disconnect-internal)
(ats-read-connection, ats-disconnect, ats-establish-connection)
(ats-connect, ats-eval, test-controller):

* test/infra/android/test-driver.el (ats-process)
(ats-connection-established, ats-header, ats-in-eval)
(ats-eval-as-printed, ats-eval-serial, ats-process-filter)
(ats-display-status-buffer, ats-establish-connection)
(ats-driver-log, ats-initiate-connection, test-driver): New
files.
This commit is contained in:
Po Lu 2025-02-24 21:06:30 +08:00
parent ae5674c758
commit 08077788db
5 changed files with 2554 additions and 0 deletions

26
test/infra/android/README Normal file
View file

@ -0,0 +1,26 @@
Copyright (C) 2025 Free Software Foundation, Inc. -*- coding: utf-8 -*-
See the end of the file for license conditions.
In this directory is a collection of scripts which arrange to upload
tests (or rather arbitrary Lisp forms) from an Emacs source repository
into an Android device, execute them "in vivo", so to speak, and
retrieve the results of their execution. While there is an automatic
testing system built around another version of these scripts, the
versions in the Emacs repository are engineered for interactive
execution only.
This file is part of GNU Emacs.
GNU Emacs is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
GNU Emacs is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with GNU Emacs. If not, see <https://www.gnu.org/licenses/>.

View file

@ -0,0 +1,332 @@
/* Launch an intent stated on the command line as an activity. -*- c-file-style: "GNU" -*-
Copyright (C) 2025 Free Software Foundation, Inc.
This file is part of GNU Emacs.
GNU Emacs is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or (at
your option) any later version.
GNU Emacs is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with GNU Emacs. If not, see <https://www.gnu.org/licenses/>. */
package ats;
import android.app.ActivityManagerNative;
import android.app.ActivityThread;
import android.app.IActivityManager;
import android.app.IApplicationThread;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import android.os.Bundle;
import android.os.IBinder;
import android.os.Looper;
import android.os.ParcelFileDescriptor;
import android.os.RemoteException;
import android.net.Uri;
import java.lang.IllegalArgumentException;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
public final class AtsStub
{
public static final String IDENT = "$Id: AtsStub.java,v 1.4 2024/06/30 04:24:39 jw Exp $";
private static void
neutralizeApplicationThread (ActivityThread thread)
{
Field field;
try
{
field = ActivityThread.class.getDeclaredField ("mAppThread");
field.setAccessible (true);
field.set (thread, null);
}
catch (NoSuchFieldException x)
{
x.printStackTrace ();
}
catch (IllegalAccessException x)
{
x.printStackTrace ();
}
}
private static int
main1 (String[] argv)
throws NoSuchMethodException, IllegalAccessException,
InvocationTargetException
{
ActivityThread thread;
Context context;
Looper.prepare ();
thread = ActivityThread.systemMain ();
context = thread.getSystemContext ();
if (argv.length < 1 || argv[0].equals ("--help"))
{
System.out.println ("AtsStub [start] [--user <USER_ID>] <INTENT>");
System.out.println (" where INTENT is a series of arguments defining an Intent,");
System.out.println (" namely,");
System.out.println (" -a <ACTION>");
System.out.println (" -d <DATA URI>");
System.out.println (" -t <TYPE>");
System.out.println (" -c <CATEGORY>");
System.out.println (" -n <COMPONENT>");
System.out.println (" -e or --es <KEY> <STRING VALUE>");
System.out.println (" --esn <KEY>");
System.out.println (" --ei <KEY> <INTEGER VALUE>");
System.out.println (" --eu <KEY> <URI VALUE>");
System.out.println (" --ecn <KEY> <COMPONENT NAME>");
System.out.println (" --eia <KEY> <INTEGER>, ...");
System.out.println (" --el <KEY> <LONG>");
System.out.println (" --ela <KEY> <LONG>, ...");
System.out.println (" --ef <KEY> <FLOAT>");
System.out.println (" --efa <KEY> <FLOAT ARRAY>");
System.out.println (" --esa <KEY> <STRING>, ...");
System.out.println (" --ez <KEY> <BOOLEAN>");
System.out.println (" -f <KEY> <FLAGS>");
return 0;
}
else if (argv[0].equals ("start"))
{
Intent intent;
int i, userID = 0;
String token, type;
Uri data;
boolean debug;
intent = new Intent ();
debug = false;
data = null;
type = null;
for (i = 1; i < argv.length; ++i)
{
int j;
token = argv[i];
if (token.equals ("-a"))
intent.setAction (argv[++i]);
else if (token.equals ("-d"))
data = Uri.parse (argv[++i]);
else if (token.equals ("-t"))
type = argv[++i];
else if (token.equals ("-c"))
intent.addCategory (argv[++i]);
else if (token.equals ("-e") || token.equals ("--es"))
{
intent.putExtra (argv[i + 1], argv[i + 2]);
i += 2;
}
else if (token.equals ("--esn"))
intent.putExtra (argv[++i], (String) null);
else if (token.equals ("--ei"))
{
int value = Integer.valueOf (argv[i + 2]);
intent.putExtra (argv[i + 1], value);
i += 2;
}
else if (token.equals ("--eu"))
{
Uri value = Uri.parse (argv[i + 2]);
intent.putExtra (argv[i + 1], value);
i += 2;
}
else if (token.equals ("--ecn"))
{
ComponentName value
= ComponentName.unflattenFromString (argv[i + 2]);
intent.putExtra (argv[i + 1], value);
i += 2;
}
else if (token.equals ("--eia"))
{
String values[] = argv[i + 2].split (",");
int array[] = new int[values.length];
for (j = 0; j < values.length; ++j)
array[j] = Integer.valueOf (values[j]);
intent.putExtra (argv[i + 1], array);
i += 2;
}
else if (token.equals ("--el"))
{
long value = Long.valueOf (argv[i + 2]);
intent.putExtra (argv[i + 1], value);
i += 2;
}
else if (token.equals ("--ela"))
{
String values[] = argv[i + 2].split (",");
long array[] = new long[values.length];
for (j = 0; j < values.length; ++j)
array[j] = Long.valueOf (values[j]);
intent.putExtra (argv[i + 1], array);
i += 2;
}
else if (token.equals ("--ef"))
{
float value = Float.valueOf (argv[i + 2]);
intent.putExtra (argv[i + 1], value);
i += 2;
}
else if (token.equals ("--efa"))
{
String values[] = argv[i + 2].split (",");
float array[] = new float[values.length];
for (j = 0; j < values.length; ++j)
array[j] = Float.valueOf (values[j]);
intent.putExtra (argv[i + 1], array);
i += 2;
}
else if (token.equals ("--esa"))
{
String[] strings;
strings = argv[i + 2].split ("(?<!\\\\),");
intent.putExtra (argv[i + 1], strings);
i += 2;
}
else if (token.equals ("--ez"))
{
boolean value = Boolean.valueOf (argv[i + 2]);
intent.putExtra (argv[i + 1], value);
i += 2;
}
else if (token.equals ("-n"))
{
ComponentName value
= ComponentName.unflattenFromString (argv[++i]);
if (value == null)
throw new IllegalArgumentException ("Invalid component name: " + argv[i]);
intent.setComponent (value);
}
else if (token.equals ("-f"))
intent.addFlags (Integer.decode (argv[++i]).intValue ());
else if (token.equals ("--user"))
{
int value = Integer.valueOf (argv[++i]);
if (value != 0
&& (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1))
throw new IllegalArgumentException ("Invalid user: " + value);
userID = value;
}
else
throw new IllegalArgumentException ("Option not understood: " + argv[i]);
}
intent.addFlags (Intent.FLAG_ACTIVITY_NEW_TASK);
intent.setDataAndType (data, type);
if ((Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1)
|| userID == 0)
{
/* mAppThread must be neutralized, or the ActivityManager
service will attempt and fail to locate a matching app
record when it is passed as the caller argument to the
startActivity RPC routine. */
neutralizeApplicationThread (thread);
context.startActivity (intent);
}
else
{
/* Otherwise, there are two revisions of startActivityAsUser
this utility must support, whose signatures follow:
(IApplicationThread, Intent, String, IBinder, String,
int, int, String, ParcelFileDescriptor, Bundle, int)
(IApplicationThread, String, Intent, String, IBinder, String,
int, int, String, ParcelFileDescriptor, Bundle, int) */
Method method;
IActivityManager am = ActivityManagerNative.getDefault ();
int rc;
Class klass = IActivityManager.class;
/* Attempt to resolve the first variant which is mostly
observed on Jelly Bean MR1 systems. */
try
{
method = klass.getMethod ("startActivityAsUser",
IApplicationThread.class,
Intent.class, String.class,
IBinder.class, String.class,
int.class, int.class,
String.class,
ParcelFileDescriptor.class,
Bundle.class, int.class);
}
catch (NoSuchMethodException e)
{
method = null;
}
if (method != null)
rc = (Integer) method.invoke (am, null, intent, intent.getType (),
null, null, 0, 0, null, null, null,
userID);
else
{
/* Now the modern `IActivityManager#startActivityAsUser'. */
method = klass.getMethod ("startActivityAsUser",
IApplicationThread.class,
String.class, Intent.class,
String.class, IBinder.class,
String.class, int.class,
int.class, String.class,
ParcelFileDescriptor.class,
Bundle.class, int.class);
rc = (Integer) method.invoke (am, null, null, intent,
intent.getType (),
null, null, 0, 0, null,
null, null, userID);
}
if (rc != 0)
{
System.err.println ("Failed to start activity as user: " + rc);
return 1;
}
}
return 0;
}
return 1;
}
public static void
main (String arg[])
{
try
{
System.exit (main1 (arg));
}
catch (Throwable e)
{
e.printStackTrace ();
System.exit (0);
}
}
};

View file

@ -0,0 +1,50 @@
Copyright (C) 2025 Free Software Foundation, Inc. -*- coding: utf-8 -*-
See the end of the file for license conditions.
This directory holds a minuscule stub executable that invokes the
appropriate Java APIs to execute an activity with string array
parameters. It is not necessary on systems that have received AOSP
commit:
https://android.googlesource.com/platform/frameworks/base/+/28600e2%5E%21/
which is to say, Android 5.0 (API 21) or newer.
------------------------------------------------------------------------
In consequence of this stub's liberal usage of private APIs, you must
compile this executable yourself, and link it against a copy of the
Android framework libraries extracted from a real installation of
Android 4.4.x and processed through `dex2jar':
https://github.com/pxb1988/dex2jar
The resultant class file, once further compiled into Dex bytecode with
`d8' and archived, must be renamed "stub.zip" and placed in this
directory; for example:
$ adb pull /system/framework/framework.jar
/system/framework/framework.jar: 1 file pulled, 0 skipped. 1.6 MB/s (3818859 bytes in 2.342s)
$ adb pull /system/framework/core.jar
/system/framework/core.jar: 1 file pulled, 0 skipped. 1.6 MB/s (1237856 bytes in 0.743s)
$ dex2jar {core,framework}.jar
dex2jar core.jar -> ./core-dex2jar.jar
dex2jar framework.jar -> ./framework-dex2jar.jar
$ javac AtsStub.java -source 1.6 -classpath "core-dex2jar.jar:framework-dex2jar.jar"
$ d8 AtsStub.class && zip stub.zip classes.dex
This file is part of GNU Emacs.
GNU Emacs is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
GNU Emacs is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with GNU Emacs. If not, see <https://www.gnu.org/licenses/>.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,210 @@
;;; Receive and execute Lisp code submitted by a test controller. -*- lexical-binding: t; -*-
;;; $Id: ats-driver.el,v 1.6 2025/02/19 01:56:55 jw Exp $
;; Copyright (C) 2025 Free Software Foundation, Inc.
;; This file is part of GNU Emacs.
;; GNU Emacs is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.
;; GNU Emacs is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with GNU Emacs. If not, see <https://www.gnu.org/licenses/>.
;;; Commentary:
;;
;; This file establishes a connection to a controlling device, executes
;; Lisp expressions received from the same, and responds with any
;; results available.
;;
;; There were anciently many more facilities in this file but they are
;; in the process of being moved to `test-controller.el' (now in Lisp).
;;; Code:
;; Connection establishment and management.
(defvar ats-process nil
"Connection to the test controller.")
(defvar ats-connection-established nil
"Whether `ats-process' has been initialized.")
(defface ats-header '((t :height 1.3 :weight bold
:inherit variable-pitch))
"Face of ATS header elements.")
(defvar-local ats-in-eval nil
"Whether an `-eval' command is being processed and form's size.")
(defvar-local ats-eval-as-printed nil
"Whether to return the values of the submitted form as a string.")
(defvar-local ats-eval-serial nil
"Serial number identifying this result.")
(defun ats-process-filter (process string)
"Filter input from `ats-process'.
Insert STRING into the connection buffer, till a full command is
read."
(when (buffer-live-p (process-buffer process))
(with-current-buffer (process-buffer process)
(let ((marker (process-mark process)))
(unless (marker-position marker)
(set-marker marker (point)))
(save-excursion
(goto-char marker)
(insert string)
(set-marker marker (point))))
(let ((firstchar (char-after (point-min)))
(inhibit-quit nil)
(in-eval ats-in-eval))
(while (or (eq firstchar ?-) in-eval)
(unless ats-in-eval
(when (eq firstchar ?-)
;; A command is being delivered. Search for a newline.
(save-excursion
(when-let* ((newline (search-forward "\n" nil t))
(command (buffer-substring
(point-min) (1- newline))))
(delete-region (point-min) newline)
(cond
((equal command "-ok")
(setq ats-connection-established t)
(ats-display-status-buffer))
((equal command "-not-accepting-connections")
(error
"The server is not accepting connections"))
((string-match
"^-incorrect-uuid \\([[:alnum:]-]\\) \\([[:alnum:]-]\\)$"
command)
(error "Connection rejected; wanted ID=%s, received ID=%s"
(match-string 2 command) (match-string 1 command)))
((string-match
"^-eval \\([[:digit:]]+\\) \\([[:digit:]]+\\) \\(t\\|nil\\)$"
command)
(setq ats-eval-serial (string-to-number
(match-string 1 command))
ats-in-eval (string-to-number
(match-string 2 command))
ats-eval-as-printed (equal
(match-string 3 command)
"t")))
(t (error (concat "Unknown command: " command))))))))
(when ats-in-eval
;; Proceed till `ats-in-eval' characters are read.
(when (>= (- (point-max) (point-min)) ats-in-eval)
(let ((value
(save-restriction
(narrow-to-region (point-min) (1+ ats-in-eval))
(condition-case err
(let* ((str (buffer-string)))
(with-current-buffer "*ATS*"
(goto-char (point-max))
(let ((inhibit-read-only t))
(insert "--> " (truncate-string-to-width
str 72)
"\n")))
(let* ((expr (car (read-from-string str)))
(value (eval expr)))
(cons 'ok value)))
(error (cons 'error err))))))
(let* ((print-escape-control-characters t)
(print-escape-newlines t)
(str (prin1-to-string value)))
(if ats-eval-as-printed
(let* ((quoted (prin1-to-string str)))
(process-send-string
process (format "\fats-request:%d %d\n"
ats-eval-serial
(length quoted)))
(process-send-string process quoted))
(process-send-string
process (format "\fats-request:%d %d\n"
ats-eval-serial
(length str)))
(process-send-string process str)))
(process-send-string process "\n"))
(delete-region (point-min)
(+ (point-min) ats-in-eval))
(setq ats-in-eval nil)))
;; Don't loop if the form data is yet to arrive.
(setq firstchar (char-after (point-min))
in-eval nil))))))
(defun ats-display-status-buffer ()
"Replace the splash screen with text announcing connection establishment."
(with-current-buffer (get-buffer-create "*ATS*")
(setq buffer-read-only nil)
(erase-buffer)
(insert (propertize "ATS ready\n" 'face 'ats-header))
(insert (propertize "
If you are reading this message, this instance of Emacs has\
successfully established a connection with a controlling\
machine and is patiently awaiting instructions.
"
'face 'variable-pitch))
(special-mode)
(setq-local truncate-lines nil)
(visual-line-mode 1))
(pop-to-buffer "*ATS*" '(display-buffer-full-frame)))
(defun ats-establish-connection (host port id)
"Connect to the test controller instance at HOST:PORT.
ID is the identifier assigned to this client. Establish a
connection to a test controller instance through an address or a
Unix domain socket provided as aforesaid. Signal an error upon
failure."
(message "; Connecting to %s:%d..." host port)
(setq ats-process (make-network-process
:name (format "*ats connection to %s:%d*" host port)
:buffer "*ats connection*"
:host host
:service port
:coding 'utf-8-emacs
:filter #'ats-process-filter))
(process-send-string ats-process (concat id "\n")))
(defun ats-driver-log (_ connection _)
"Log function for ATS driver processes."
(if ats-process
(delete-process connection)
(setq ats-process connection)
(set-process-filter connection #'ats-process-filter)
(pop-to-buffer (process-buffer connection))))
(defun ats-initiate-connection (commfile)
"Open a network server locally to which the controller may connect.
Write its port number to COMMFILE, and await a connection from
the controller."
(let* ((process (make-network-process :name " *ats driver*"
:server t
:host 'local
:service t
:family 'ipv4
:coding 'utf-8-emacs
:log #'ats-driver-log))
(service (process-contact process :service)))
(with-temp-buffer
(insert (format "%d\n" service))
(write-region (point-min) (point-max) commfile t))
(message "; Listening for connection from controller at localhost:%d"
service)))
(provide 'test-driver)
;;; test-driver.el ends here
;; Local Variables:
;; emacs-lisp-docstring-fill-column: 64
;; indent-tabs-mode: t
;; End: