Konubinix' site

A Python Runtime on Android

Fleeting

I want to get access to the android java classes from python and in particular connect android and a board with a single microcontroller, hence being able to use something like https://github.com/jacklinquan/usbserial4a. Although there are several libraries that provide access to java classes from python, this one depends on pyjnius. Creating a compatibility layer should not be that hard, but I would rather avoid that, so I will prefer solution encompassing pyjnius.

My final objective is to be able to quickly prototype DIY funny useless electronic stuffs using old phones. To have a fast feedback loop, I would remotely control the phone (using rpyc1) from the comfort of my PC. The phone would provide the battery, processing power and its classical hardware (GNSS, light…) while the microcontroller would provide analog control to deal with motors, sensors, etc. The phone would provide power to the MCU over USB while communicating using serial interface. Because ESP8266 and ESP32 provide wifi and bluetooth, this requirement might be put into question, but it still makes sense when I want to use some disconnected boards like an uno r3.

I will likely need to install the phone application several times, so this should be as easy as dropping a file in the sdcard and that’s all.

I don’t need a graphical interface, only a mean to control it remotely2.

I have access to a powerful arm mac or I can use my cheap amd64 pc. I’d rather use the former for it is muuuuch faster.

trying using buildozer

I have already played a lot in the past with kivy and buildozer. Also, this is were pyjnius was born. Therefore, this is the obvious first candidate.

I used to use the kivy/buildozer image in the past, but it seems abandoned now. So let’s follow the documentation using earthly to get the best of both worlds: an evolving setup with the reproducibility of earthly.

I did a lot of trials and errors to find a configuration that works. The variables where:

Here are all my trials. It took ages because the cache is very poorly handled in buildozer/p4a, and I often needed to clean the cache folders to actually understand what happened. Of course, cleaning the cache means very long builds.

VERSION 0.8

IMPORT github.com/Konubinix/Earthfile AS e

generate:
    FROM e+debian-python-user-venv --packages="buildozer" --extra_packages="sed"
    WORKDIR /app
    ARG name=proofofconcept
    RUN buildozer init
    RUN sed -i -r \
        -e "s/^(title = .+)$/title = ${name}/" \
        -e "s/^(package.name = .+)$/package.name = ${name}/" \
        -e 's/^(requirements = .+)$/\1,android,pil,rpyc,plyer,pyjnius   /' \
        -e 's/^(#android.permissions = .+)$/android.permissions = CAMERA,VIBRATE,INTERNET,READ_EXTERNAL_STORAGE,MANAGE_EXTERNAL_STORAGE/' \
        buildozer.spec
    # RUN echo "p4a.branch = v2024.01.21" >> buildozer.spec
    SAVE ARTIFACT buildozer.spec AS LOCAL buildozer.spec

apk:
    # https://buildozer.readthedocs.io/en/latest/installation.html
    # on ubuntu 22.04, macos --platform amd64, jdk 17 => sdkmanager platform-tools -> Warning: IO exception while downloading manifest
    #    replay with --verbose gives -> java.security.NoSuchAlgorithmException: in sdkmanager
    # same with 20.04
    # on ubuntu 22.04, macos --platform amd64, jdk 18 => sdkmanager "platforms-tools" -> too many errors (no more info in --verbose)
    # on ubuntu 22.04, macos --platform amd64, jdk 21 => sdkmanager "platforms;android-31" -> too many errors, Core dumped (no more info in --verbose)
    # on ubuntu 22.04, macos --platform amd64, jdk 17 manually installed like briefcase => sdkmanager "platforms-tools" -> too many errors (no more info in --verbose)
    # on debian, macos --platform amd64, jdk 17 manually installed like briefcase =>
    # on debian, macos --platform arm64, jdk 17 manually installed like briefcase => Aidl cannot be executed You might have missed to install 32bits libs
    # on debian, linux amd64, jdk17 -> ok
    #FROM ubuntu:22.04
    # hack to avoid 20.04 from asking for the timezone -> https://github.com/microsoft/AirSim/issues/3877
        # ARG DEBIAN_FRONTEND=noninteractive
        # ENV TZ=Europe/Paris
        # RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
    # end of hack
    # RUN apt update && apt install -y git zip unzip openjdk-17-jdk python3-pip autoconf libtool pkg-config zlib1g-dev libncurses5-dev libncursesw5-dev libtinfo5 cmake libffi-dev libssl-dev
    # RUN apt install -y libffi-dev zlib1g-dev
    # RUN pip3 install --upgrade buildozer Cython==0.29.33 # virtualenv  # the --user should be removed if you do this in a venv
    FROM e+debian-python-user-venv --packages "buildozer Cython==0.29.33" --extra_packages="build-essential libssl-dev automake curl patch libffi-dev zlib1g-dev  openjdk-17-jdk git zip unzip python3-pip autoconf libtool pkg-config zlib1g-dev libncurses5-dev libncursesw5-dev libtinfo5 cmake libffi-dev libssl-dev"
    USER root
    ENV VIRTUAL_ENV=y # to avoid ths --user
    # install jdk manually, cloning the bebavior of briefcase
        # RUN apt purge -y openjdk-17-jdk
        # RUN apt update && apt install -y curl

        # ENV JDK_MAJOR_VER=17
        # ENV JDK_RELEASE=17.0.12
        # ENV JDK_BUILD=7
        # ENV JAVA_HOME=/opt/openjdk
        # ENV PATH="${JAVA_HOME}/bin:${PATH}"

        # RUN curl --fail --silent --show-error --location -o /tmp/openjdk.tar.gz \
        #         https://github.com/adoptium/temurin${JDK_MAJOR_VER}-binaries/releases/download/jdk-${JDK_RELEASE}+${JDK_BUILD}/OpenJDK${JDK_MAJOR_VER}U-jdk_x64_linux_hotspot_${JDK_RELEASE}_${JDK_BUILD}.tar.gz && \
        #     mkdir -p ${JAVA_HOME} && \
        #     tar -xzf /tmp/openjdk.tar.gz -C ${JAVA_HOME} --strip-components=1 && \
        #     rm /tmp/openjdk.tar.gz
    # end of install

    WORKDIR /app
    COPY --dir main.py /app
    ARG gen=yes
    IF test "${gen}" = "yes"
        COPY --dir +generate/buildozer.spec /app/
    ELSE
        COPY --dir ./buildozer.spec /app/
    END
    CACHE /root/.buildozer
    CACHE /app/.buildozer
    ARG cleanapp=no
    IF test "${cleanapp}" = "yes"
       RUN --no-cache rm -rf /app/.buildozer/*
    END
    ARG cleanroot=no
    IF test "${cleanroot}" = "yes"
       RUN --no-cache rm -rf /root/.buildozer/*
    END
    RUN apt update
    RUN apt install -y libltdl-dev
    RUN yes y | buildozer android debug | grep -vi download
    RUN mv bin/*apk app.apk
    SAVE ARTIFACT app.apk AS LOCAL app.apk

I somehow managed to get a build from some of those setups, but barely that I could reproduce easily after cleaning everything.

In the end, the only setup that kinda worked without much hassle was the officially documented one: amd64, ubuntu 22.04, and the recommended list of packages. Too bad for the use of mac…

There are several great features that come with this setup:

I can even run the app as a service, making it run in the background.

As a conclusion, it works and should continue working as long as kivy gets some momentum. I had to play a lot with it and dig into several github issues to find some solutions, so it was definitely not easy. But, it appears to provide enough flexibility to adapt to my edge case pretty well.

trying with with briefcase

This is the new kid in the block. It uses chaquopy, that feels more curated and less hacky than python-for-android, but younger and much less featureful.

Unfortunately, adding a built dependency such as pyjnius appears to be hard. Trying using it will raise: I get "Chaquopy cannot compile native code". They have no incentive making it available for they already provide an alternative. See how jpype won’t make it in there. On the other hand, it might be an hint that they want to curate a small subset of working libraries, so that might be a good sign after all.

Also, it needs some boilerplate code and therefore might lead to a difficult maintenance.

In addition, it comes with the graphical interface and it is not clear whether I can avoid using it or not.

VERSION 0.8

IMPORT github.com/Konubinix/Earthfile AS e

generate:
    FROM e+debian-python-user-venv --packages "briefcase" --extra_packages="git"
    WORKDIR /app
    RUN briefcase new --no-input
    RUN sed -i -r 's/^requires = \[/requires = \["rpyc",/' helloworld/pyproject.toml
    SAVE ARTIFACT helloworld AS LOCAL helloworld

apk:
    FROM --platform linux/amd64 e+debian-python-user-venv --packages "briefcase" --extra_packages="python3 git build-essential pkg-config python3-dev python3-venv"
    WORKDIR /app
    # CACHE /app/build # is not updated automatically ?
    CACHE /home/sam/.cache
    CACHE /root/.gradle
    USER root # to get access to the cache
    ARG cleanapp=no
    IF apk "${cleanapp}" = "yes"
       RUN --no-cache rm -rf /home/sam/.cache/*
    END
    ARG cleanroot=no
    IF apk "${cleanroot}" = "yes"
       RUN --no-cache rm -rf /root/.gradle/*
    END
    ARG gen=yes
    IF test "${gen}" = "yes"
        COPY --dir +generate/helloworld /app/
    ELSE
        COPY --dir ./helloworld /app/
    END
    COPY --dir ./app.py /app/helloworld/src/helloworld/app.py
    WORKDIR /app/helloworld
    RUN yes y | briefcase create android --no-input
    RUN briefcase build android
    RUN mv /app/helloworld/build/helloworld/android/gradle/app/build/outputs/apk/debug/app-debug.apk app.apk
    SAVE ARTIFACT app.apk AS LOCAL app.apk

It will work in mac, with the amd64 emulation, so that’s a plus. With arm64 it will complain that “Briefcase cannot install Android SDK on this machine.”

Takes 13 minutes.

Let’s replace the code of the app by the call to rpyc.

nil

As the time of writing this, there is no way to run the application as a service (https://github.com/beeware/briefcase/discussions/1553).

As a conclusion, this technology is more opinionated about using the full beeware stack and in particular running a toga application. It does this job pretty well. But for edge cases like mine, it feels like it might not be a good candidate.

do it for real

As per my previous experiments, I decided to use buildozer.

the build

To use usbserial4a, the documentation says that I need to add a content filter. I actually found out that removing it did not change the fact I could discuss with the board.

This is the content that was asked to add.

<intent-filter>
  <action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
</intent-filter>

<meta-data android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
           android:resource="@xml/device_filter" />

Now, let’s create an application, adding a bunch of dependencies that I deem useful.

VERSION 0.8

IMPORT github.com/Konubinix/Earthfile AS e

generate:
    FROM e+debian-python-user-venv --packages="buildozer" --extra_packages="sed"
    WORKDIR /app
    ARG title=proofofconcept
    ARG name=poc
    ARG domain=eu.konix
    RUN buildozer init
    # permissions taken from https://github.com/kivy/kivy-launcher/blob/main/buildozer.spec
    # plumbum is required by rpyc
    ARG extra=""
    RUN sed -i -r \
        -e "s/^(title = .+)$/title = ${title}/" \
        -e "s/^(package.name = .+)$/package.name = ${name}/" \
        -e "s/^(package.domain = .+)$/package.domain = ${domain}/" \
        -e "s/^(requirements = .+)$/\1,android,plumbum,rpyc,plyer,pyjnius${extra}/" \
        -e "s/# android.accept_sdk_license = False/android.accept_sdk_license = True/" \
        -e 's/^(#android.permissions = .+)$/android.permissions = CAMERA,VIBRATE,MANAGE_EXTERNAL_STORAGE,READ_EXTERNAL_STORAGE,WRITE_EXTERNAL_STORAGE,ACCESS_LOCATION_EXTRA_COMMANDS,ACCESS_NETWORK_STATE,ACCESS_NOTIFICATION_POLICY,ACCESS_WIFI_STATE,BLUETOOTH,BLUETOOTH_ADMIN,BROADCAST_STICKY,CHANGE_NETWORK_STATE,CHANGE_WIFI_MULTICAST_STATE,CHANGE_WIFI_STATE,DISABLE_KEYGUARD,EXPAND_STATUS_BAR,FOREGROUND_SERVICE,GET_PACKAGE_SIZE,INSTALL_SHORTCUT,INTERNET,KILL_BACKGROUND_PROCESSES,MANAGE_OWN_CALLS,MODIFY_AUDIO_SETTINGS,NFC,READ_SYNC_SETTINGS,READ_SYNC_STATS,RECEIVE_BOOT_COMPLETED,REORDER_TASKS,REQUEST_COMPANION_RUN_IN_BACKGROUND,REQUEST_COMPANION_USE_DATA_IN_BACKGROUND,REQUEST_DELETE_PACKAGES,REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,SET_ALARM,SET_WALLPAPER,SET_WALLPAPER_HINTS,TRANSMIT_IR,USE_FINGERPRINT,VIBRATE,WAKE_LOCK,WRITE_SYNC_SETTINGS,BLUETOOTH, BLUETOOTH_ADMIN, BLUETOOTH_SCAN, BLUETOOTH_CONNECT, BLUETOOTH_ADVERTISE, ACCESS_FINE_LOCATION/' \
        -e "s|^(#services = NAME:ENTRYPOINT_TO_PY,NAME2:ENTRYPOINT2_TO_PY)$|services = ${name}:./service.py:foreground:sticky|" \
        buildozer.spec
    # RUN echo "p4a.branch = v2024.01.21" >> buildozer.spec
    # appears not to be required -> -e "s/^#android.manifest.intent_filters =.+$/android.manifest.intent_filters = intent-filter.xml/" \

    SAVE ARTIFACT buildozer.spec AS LOCAL buildozer.spec

apk:
    # https://buildozer.readthedocs.io/en/latest/installation.html
    FROM ubuntu:22.04
    ARG extra_deps="ccache"
    ARG lzma_deps="autopoint"
    RUN apt update && apt install -y ${lzma_deps} ${extra_deps} git zip unzip openjdk-17-jdk python3-pip autoconf libtool pkg-config zlib1g-dev libncurses5-dev libncursesw5-dev libtinfo5 cmake libffi-dev libssl-dev
    RUN pip3 install --upgrade buildozer Cython==0.29.33 # virtualenv  # the --user should be removed if you do this in a venv
    WORKDIR /app
    CACHE /root/.buildozer
    CACHE /app/.buildozer
    CACHE /root/.gradle
    ARG cleanapp=no
    IF test "${cleanapp}" = "yes"
       RUN --no-cache rm -rf /app/.buildozer/*
    END
    ARG cleanroot=no
    IF test "${cleanroot}" = "yes"
       RUN --no-cache rm -rf /root/.buildozer/* /root/.gradle/*
    END
    ARG gen=yes
    IF test "${gen}" = "yes"
        # I added several dependencies that I wish I add in my previous PoC, so that I will be able to use them when my current phone will become old see below my experiment with the wiko cink peax
        # some packages that I wanted but cannot build
        # - scipy needs lapack that needs fortran that needs a legacy ndk
        # - zmq fails https://github.com/kivy/python-for-android/issues/2706
        ARG extra=",able_recipe,dbus-next,usbserial4a,usb4a,Pillow,av,av_codecs,cryptography,ffpyplayer,ffpyplayer_codecs,matplotlib,pandas,pycryptodome,sqlite3,requests,opencv,opencv_extras"
        COPY --dir (+generate/buildozer.spec --extra="${extra}") /app/
    ELSE
        COPY --dir ./buildozer.spec /app/
    END
    COPY --dir service.py main.py intent-filter.xml /app
    # skip the download lines to avoid killing my pc with all the output
    RUN yes y | buildozer android debug | grep -vi download && mv bin/*apk app.apk
    SAVE ARTIFACT app.apk AS LOCAL app.apk

Now, let’s build it

Takes 35 minutes with no cache and 1 minute when the cache is warm and I only edited a python file. 25 minutes with a warn /root. It generates a 40M apk.

And with all the dependencies: 1h30m with a cleanapp=yes but a fresh cache for the rest. It generates a 150M apk.

the entrypoints

I want the application to load whatever code I decided to put in the sdcard.

Therefore, I need to split the code in two: the part that will be run inside the apk, and the part inside the sdcard. The former is responsible to find and run the later.

inside the apk

The main application will try to find /sdcard/poc/poc.py:main()

import sys
from jnius import autoclass
from pathlib import Path

from android.permissions import request_permissions, Permission
request_permissions([Permission.WRITE_EXTERNAL_STORAGE,Permission.READ_EXTERNAL_STORAGE])

print("STARTED MAIN")
Environment = autoclass('android.os.Environment')
sys.path.append(str(Path(Environment.getExternalStorageDirectory().absolutePath) / "poc"))

from poc import main
main()

I did not put any wait on the permissions, therefore, it will likely crash the first time will you press the accept button and work the second time.

Also, if you are running android 10+, you will have to accept that the application has access to the sdcard.

clk android adb shell appops set --uid eu.konix.poc MANAGE_EXTERNAL_STORAGE allow

For more information, see the note about accessing the files.

The service will try to run /sdcard/poc/poc.py:service()

import sys
from jnius import autoclass
from pathlib import Path

print(f"STARTED SERVICE -> {os.environ['PYTHON_SERVICE_ARGUMENT']}")
Environment = autoclass('android.os.Environment')
sys.path.append(str(Path(Environment.getExternalStorageDirectory().absolutePath) / "poc"))

from poc import service
service()

inside the sdcard

Now, you can put whatever you want in the main() and service() functions in /sdcard/poc/poc.py

I personally decided to split the main and service code in separate modules.

Therefore, I created a directory sdcard/poc/app in which I put the service and the main code.

My entry point (/sdcard/poc/poc.py) looks like this

def main(*args, **kwargs):
    from app import main
    main.run()


def service(*args, **kwargs):
    from app import service
    service.run()

Then, I created a main that simply runs the service and exits.

def run():
    from jnius import autoclass
    service = autoclass('eu.konix.poc.ServicePoc')
    mActivity = autoclass('org.kivy.android.PythonActivity').mActivity
    argument = "FOOBAR"
    service.start(mActivity, 'presplash', 'title', 'content', argument)

To make my android a remotely controlled device, I will simply run rpyc.

Note that I need to import a few stuff for rpyc to correctly provide them afterwards. I learn along the way and add them when I realize I need them.

I like being notified when the service is ready, hence the vibration at the beginning.

About PythonService.mService.setAutoRestartService(True), your mileage may vary. I decided that I wanted a long running service and not bother restarting it. This is also why I defined it as foreground and sticky in the buildozer definition.

import os
import sys
from jnius import autoclass
from pathlib import Path
# I need to start using at least one function of plyer from here, otherwise,
# when controlling from rpyc, it will raise notimplementederror
import plyer
# Same, trigger the load from here so that rpyc is ok
from usbserial4a import serial4a
from able import BluetoothDispatcher, GATT_SUCCESS

def run():
    plyer.vibrator.vibrate()
    print(f"STARTED SERVICE POC -> {os.environ['PYTHON_SERVICE_ARGUMENT']}")
    # see https://python-for-android.readthedocs.io/en/develop/services.html
    Environment = autoclass('android.os.Environment')
    PythonService = autoclass('org.kivy.android.PythonService')
    PythonService.mService.setAutoRestartService(True)
    rpyc()

def rpyc():
    nil

make it shine!

As described in the note about accessing the files, from android 10, you need to explicitly give the consent for the application to access the files.

This can be done from the command line.

clk android adb shell appops set --uid eu.konix.poc MANAGE_EXTERNAL_STORAGE allow

Or from the dedicated graphical interface that you will find in the settings.

I personally start it with

clk android adb shell am start -a android.settings.MANAGE_ALL_FILES_ACCESS_PERMISSION

Then, you can run the application.

clk android adb shell am start -n eu.konix.poc/org.kivy.android.PythonActivity
Starting: Intent { cmp=eu.konix.poc/org.kivy.android.PythonActivity }

And to stop it

clk android adb shell am force-stop eu.konix.poc

You should feel the phone vibrating and the

to run a graphical user interface

Pick whatever example from https://github.com/kivy/kivy/tree/master/examples and put it in /sdcard/poc/app/myexample

As long as the app loads this example, you should be good to go.

While trying this, I found several limitations though.

  1. the audio example makes the application crash after the first sound is played, (looks linked to kivy on android),
  2. the demo/showcase fails because kivy.extras.highlight.KivyLexer is not part of the build
  3. the demo/multistroke fails because either kivy does not find the .kv files, or it does not manage to import the historymanager. The later is easily fixed with appending the path to sys.path, but I don’t know yet how to fix the former. It might not be a big deal, but not something I want to tackle for now.

As long as the application is quite simple, I suppose this should work.

For instance, take the demo/pictures one, drop it in /sdcard/poc/app/pictures and make the main.py:run() code load this example.

def run():
    from .pictures.main import PicturesApp
    PicturesApp().run()

It works without issue on my bullhead

note about accessing the files

Before android 10, an application could read and write the sdcard, provided the application asked for it.

Introduced in 10 and enforced in 11, now applications have to use the scoped storage, in which the application won’t directly access the files, but asks the user for files using dedicated intents.

Fortunately, there is still a way to circumvent this, using MANAGE_EXTERNAL_STORAGE that provides file access to the application. It was meant for applications that needs to get them, because it does not make sense otherwise, like antivirus. In my mind, this is one of those use cases.

Once installed, one needs to find the setting to “allow access to all files” in android to allow the application.

clk android adb shell am start -a android.settings.MANAGE_ALL_FILES_ACCESS_PERMISSION
Starting: Intent { act=android.settings.MANAGE_ALL_FILES_ACCESS_PERMISSION }

You can do this automatically

clk android adb shell appops set --uid eu.konix.poc MANAGE_EXTERNAL_STORAGE allow

Doing this, you can continue loading a python module from the sdcard and get a very fast feedback loop.

note about permissions

Apart from the more specific case of MANAGE_EXTERNAL_STORAGE, you can grant permission from the command line using commands such as

clk android adb shell pm grant eu.konix.poc android.permission.CAMERA

trying with an arduino board

Using an UNO R3 and the code from with arduino.

Now, let’s try to play with it from the comfort of my computer, while plugged on my phone.

import rpyc

c = rpyc.classic.connect(ip, int(port))

usb = c.modules.usb4a.usb
serial4a = c.modules.usbserial4a.serial4a
from pprint import pprint

usb_device_list = usb.get_usb_device_list()
usb_device_name_list = [device.getDeviceName() for device in usb_device_list]
usb_device_dict = {
    device.getDeviceName():[            # Device name
        device.getVendorId(),           # Vendor ID
        device.getManufacturerName(),   # Manufacturer name
        device.getProductId(),          # Product ID
        device.getProductName()         # Product name
    ] for device in usb_device_list
}
pprint(usb_device_dict)

portstr = usb_device_list[0].getDeviceName()
device = usb.get_usb_device(portstr)

if not usb.has_usb_permission(device):
    usb.request_usb_permission(device)
    exit(0) # needs to wait for the user input before going further

serial_port = serial4a.get_serial_port(
    portstr,
    9600,   # Baudrate
    8,      # Number of data bits(5, 6, 7 or 8)
    'N',    # Parity('N', 'E', 'O', 'M' or 'S')
    1,      # Number of stop bits(1, 1.5 or 2)
    timeout=1,
)
if not serial_port.is_open:
    print("Serial port not open, that's strange")
    exit(1)

serial_port.write(b'Hello world!')
print([line.decode().strip() for line in serial_port.readlines()])
serial_port.close()
{'/dev/bus/usb/001/002': [9025, 'Arduino (www.arduino.cc)', 67, None]}
['Received: H', 'Received: e', 'Received: l', 'Received: l', 'Received: o', 'Received:', 'Received: w', 'Received: o', 'Received: r', 'Received: l', 'Received: d', 'Received: !']

That rocks !

note about building on old phones

To build for android 4, chatgpt tells me that I need to target the API 19 (from 14 to 19) and ndk 19, the sdkmanager teels me that

+apk | [WARNING]: Target API 19 < 30 +apk | [WARNING]: Target APIs lower than 30 are no longer supported on Google Play, and are not recommended.

Not being able to easily recreated an old apk incites me to create a very generic application with as many useful dependencies as needed and allowing to drop some files to the sdcard and archive it. That way, I can get back to an old phone when I want instead of throwing them away.

Even so, the differences of versions of python/rpyc etc will most likely make this difficult, but at least I will be able to try.

archiving for later

Because building on old phones might be difficult, let’s try to build one and keep it for later.

with sdk 31, ndk 23b

without extras

earthly --config /dev/null +apk --extra="" && ipfa app.apk
[[https://konubinix.eu/ipfs/bafybeigh4zdivb4tfqfpbrznizmwaygy3d6llx2mbd2ozcifwxttu4xguu?filename=app.apk][app.apk]]

37M

earthly --config /dev/null +generate --extra="" && ipfa buildozer.spec
[[https://konubinix.eu/ipfs/bafkreigap2bvloa6oxwvagj53vmpgjwr37bnoebmigdweiy5hfvhe5mxre?filename=buildozer.spec][buildozer.spec]]

with extras

earthly --config /dev/null +apk && ipfa app.apk
[[https://konubinix.eu/ipfs/bafybeiernjcglckm7gmnyntwrntgopfs3aq6zejhiydsazbmy54ji2b7oa?filename=app.apk][app.apk]]

146M

earthly --config /dev/null +generate && ipfa buildozer.spec
[[https://konubinix.eu/ipfs/bafkreigap2bvloa6oxwvagj53vmpgjwr37bnoebmigdweiy5hfvhe5mxre?filename=buildozer.spec][buildozer.spec]]

I tried on my zte blade s6 running android 5, I could communicate with the arduino board easily.

a legacy from another lifetime

It is actually not the first time I wanted to do this. Here is an apk that should work on older phone.

poc.apk

It will add /sdcard/poc in the sys.path an run “from main import run; run()”.

I don’t remember exactly when I created it, so I had to play a bit with it to find out what’s inside.

All of those are now available with new major version (hence incompatible ones).

Plus, python 2 reached its end of life in 2020, so using it might be like shooting oneself in the foot.

trying on my old wiko cink peax

Because I have an old phone (android 4.1.2), I decided to give it a try.

clk android -d cinkpeax adb install poc.apk
clk android -d cinkpeax adb shell am start -n org.poc.poc/org.kivy.android.PythonActivity

I tried putting the following code in the /sdcard/poc/main.py file

import os

os.a = (locals(), globals())
os.environ["NO_COLOR"] = "1"

from rpyc.core import SlaveService
from rpyc.utils.server import ForkingServer
import rpyc
import sys

print("VERSIONS: " + str(rpyc.__version__) + sys.version)


def run():
    ForkingServer(
        SlaveService,
        hostname="0.0.0.0",
        port=9999,
        reuse_addr=True,
    ).start()

Fortunately, python 2 is still available in nix, although considered “INSECURE”.

Note: installing kivy 1.10.0 was is involved, because it needs cython and some compilation steps. I did not try this. I would only need this to be able to test a graphical interface before uploading it to the phone, so that’s not a blocker.

I pushed the code of usb4a and usbserial4a in the phone.

I could play with it, but several functions did not work, at least not using rpyc.

>>> usb.get_usb_device_list()
[<android.hardware.usb.UsbDevice at 0x53777de0 jclass=android/hardware/usb/UsbDevice jself=<LocalRef obj=0x1de0086e at 0x52f60550>>]

In [34]: d.getDeviceClass()                                                                                                                                                                                    (6 results) 11:52:01 [514/2375]
Out[34]: 2

In [35]: d.getDeviceName()
Out[35]: '/dev/bus/usb/001/006

In [37]: d.getDeviceProtocol()
Out[37]: 0

In [39]: device.getManufacturerName()
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
<ipython-input-39-321147138486> in <module>()
----> 1 device.getManufacturerName()

/home/sam/test/kivy/venv/lib/python2.7/site-packages/rpyc/core/netref.pyc in __getattr__(self, name)                                                                                                                                              151             return syncreq(self, consts.HANDLE_GETATTR, name)
    152     def __getattr__(self, name):
--> 153         return syncreq(self, consts.HANDLE_GETATTR, name)
    154     def __delattr__(self, name):
    155         if name in _local_netref_attrs:

/home/sam/test/kivy/venv/lib/python2.7/site-packages/rpyc/core/netref.pyc in syncreq(proxy, handler, *args)
     70         raise ReferenceError('weakly-referenced object no longer exists')
     71     oid = object.__getattribute__(proxy, "____oid__")
---> 72     return conn.sync_request(handler, oid, *args)
     73
     74 def asyncreq(proxy, handler, *args):

/home/sam/test/kivy/venv/lib/python2.7/site-packages/rpyc/core/protocol.pyc in sync_request(self, handler, *args)
    521         isexc, obj = self._sync_replies.pop(seq)
    522         if isexc:
--> 523             raise obj
    524         else:
    525             return obj

AttributeError: 'android.hardware.usb.UsbDevice' object has no attribute 'getManufacturerName

========= Remote Traceback (1) =========
Traceback (most recent call last):
  File "/home/test/project/.buildozer/android/platform/build/dists/poc/private/lib/python2.7/site-packages/rpyc/core/protocol.py", line 347, in _dispatch_request
  File "/home/test/project/.buildozer/android/platform/build/dists/poc/private/lib/python2.7/site-packages/rpyc/core/protocol.py", line 630, in _handle_getattr
  File "/home/test/project/.buildozer/android/platform/build/dists/poc/private/lib/python2.7/site-packages/rpyc/core/protocol.py", line 596, in _access_attr
AttributeError: 'android.hardware.usb.UsbDevice' object has no attribute 'getManufacturerName

In [48]: usb.has_usb_permission(device)
---------------------------------------------------------------------------
jnius.jnius.JavaException                 Traceback (most recent call last)
<ipython-input-48-e5961d8985eb> in <module>()
----> 1 usb.has_usb_permission(device)

/home/sam/test/kivy/venv/lib/python2.7/site-packages/rpyc/core/netref.pyc in __call__(_self, *args, **kwargs)
    197         def __call__(_self, *args, **kwargs):
    198             kwargs = tuple(kwargs.items())
--> 199             return syncreq(_self, consts.HANDLE_CALL, args, kwargs)
    200         __call__.__doc__ = doc
    201         return __call__

/home/sam/test/kivy/venv/lib/python2.7/site-packages/rpyc/core/netref.pyc in syncreq(proxy, handler, *args)
     70         raise ReferenceError('weakly-referenced object no longer exists')
     71     oid = object.__getattribute__(proxy, "____oid__")
---> 72     return conn.sync_request(handler, oid, *args)
     73
     74 def asyncreq(proxy, handler, *args):

/home/sam/test/kivy/venv/lib/python2.7/site-packages/rpyc/core/protocol.pyc in sync_request(self, handler, *args)
    521         isexc, obj = self._sync_replies.pop(seq)
    522         if isexc:
--> 523             raise obj
    524         else:
    525             return obj

jnius.jnius.JavaException: JVM exception occurred: Unknown exception code: 1401250256 msg null

========= Remote Traceback (1) =========
Traceback (most recent call last):
  File "/home/test/project/.buildozer/android/platform/build/dists/poc/private/lib/python2.7/site-packages/rpyc/core/protocol.py", line 347, in _dispatch_request
  File "/home/test/project/.buildozer/android/platform/build/dists/poc/private/lib/python2.7/site-packages/rpyc/core/protocol.py", line 624, in _handle_call
  File "/sdcard/poc/usb4a/usb.py", line 112, in has_usb_permission
    return usb_manager.hasPermission(usb_device)
  File "jnius/jnius_export_class.pxi", line 906, in jnius.jnius.JavaMultipleMethod.__call__ (jnius/jnius.c:29136)
  File "jnius/jnius_export_class.pxi", line 638, in jnius.jnius.JavaMethod.__call__ (jnius/jnius.c:25547)
  File "jnius/jnius_export_class.pxi", line 732, in jnius.jnius.JavaMethod.call_method (jnius/jnius.c:26739)
  File "jnius/jnius_utils.pxi", line 93, in jnius.jnius.check_exception (jnius/jnius.c:4317)
JavaException: JVM exception occurred: Unknown exception code: 1401250256 msg null

But, putting this code inside the main.py, I could manage to get some communication with the arduino board.

import os
from usbserial4a import serial4a
from usb4a import usb

get_serial_port = serial4a.get_serial_port

l = usb.get_usb_device_list()
d = l[0]
portstr = d.getDeviceName()
device = usb.get_usb_device(portstr)

usb.request_usb_permission(device)

serial_port = serial4a.get_serial_port(
    portstr,
    9600,  # Baudrate
    8,  # Number of data bits(5, 6, 7 or 8)
    'N',  # Parity('N', 'E', 'O', 'M' or 'S')
    1,  # Number of stop bits(1, 1.5 or 2)
    timeout=1,
)

serial_port.write(b'Hello world!')
message = [line.decode().strip() for line in serial_port.readlines()]
#serial_port.close()

os.a = (locals(), globals())
os.environ["NO_COLOR"] = "1"

from rpyc.core import SlaveService
from rpyc.utils.server import ForkingServer
import rpyc
import sys

print("VERSIONS: " + str(rpyc.__version__) + sys.version)


def run():
    ForkingServer(
        SlaveService,
        hostname="0.0.0.0",
        port=9999,
        reuse_addr=True,
    ).start()

Now, I can see some data flowing between the devices, although some of it gets lost.

In [120]: serial_port.write("truc")
Out[120]: 4

In [121]: serial_port.readline()
Out[121]: 'Reeceived: r\r\n

In [122]: serial_port.readline()
Out[122]: 'Received: u\r\n

In [123]: serial_port.readline()
Out[123]: 'Received: c\r\n

In [124]: serial_port.readline()
Out[124]:

As a conclusion, I proved that I could make something out of an old phone and play with some board, but it lacked a bit of the fast feedback loop aspect that makes python enjoyable in the first place.

Also, I was lucky that I could still run ipython with python2 and rpyc3.

There are so many abandoned old phones out there with which I would have less troubles. Thus, I don’t think this use case is worth it.

Notes linking here


  1. It would have been awesome to use a jupyter kernel instead, but last time I tried, building libzmq was veeery difficult. ↩︎

  2. And even if I wanted a, interface, I would likely create a progressive web app using alpine.js, and discussing with the app on 127.0.0.1, and not use whatever the app framework provides to have a fast feedback loop.

     ↩︎