Как запускать приложение и сервис написанные на python (Kivy) под Android при запуске устройства. Что бы это сделать придется разбираться как работает buildozer и pythonforandroid. Т.к. на текущий момент сделать это по человечески не представляется возможным, из-за того что разработчики Kivy не позаботились об этом. Узнать что такое Kivy, как собрать первое приложение можно здесь или по ссылкам в конце статьи.
Мне помогли две статьи: Разработка игры под Android на Python на базе Kivy. От А до Я: подводные камни и неочевидные решения. Часть 1 и Android. Автозапуск приложения при загрузке: теория и практика. В первой автор не описал ключевые нюансы что, как, откуда и почему берется, а так же информация там частично устарела. Вторая дает понимание как работает механизм автозагрузки сервисов в Android. В сумме они помогли понять в какую сторону копать...
Разобравшись в работе определил два способа как сделать автозагрузку.
Неправильный
Что бы сервис программы загрузился после включения устройства нужно создать обработчик сигналов и обработать сигналы BOOT_COMPLETED или QUICKBOOT_POWERON, которые шлет Android после загрузки системы всем программам. Эти сигналы надо принять и обработать. Сигналы которые сможет принять приложение прописываются в файле AndroidManifest.xml, только при разработке на Kivy он не доступен в явном виде. И более того после каждой сборки проекта он генерируется заново.
buildozer android debug
Поэтому пришлось поискать файл который берется за его основу. Это AndroidManifest.tmpl.xml
При первой сборке проекта, buildozer скачает python-for-android и разместит его в папке проекта (kivy_service_test):
./kivy_service_test/.buildozer/android/platform/python-for-android/
Соответственно AndroidManifest.tmpl.xml будет в:
./kivy_service_test/.buildozer/android/platform/python-for-android/pythonforandroid/bootstraps/sdl2/build/templates/AndroidManifest.tmpl.xml
Он нам и нужен. Его содержимое:
<?xml version="1.0" encoding="utf-8"?>
<!-- Replace org.libsdl.app with the identifier of your game below, e.g.
com.gamemaker.game
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="{{ args.package }}"
android:versionCode="{{ args.numeric_version }}"
android:versionName="{{ args.version }}"
android:installLocation="auto">
<supports-screens
android:smallScreens="true"
android:normalScreens="true"
android:largeScreens="true"
android:anyDensity="true"
{% if args.min_sdk_version >= 9 %}
android:xlargeScreens="true"
{% endif %}
/>
<!-- Android 2.3.3 -->
<uses-sdk android:minSdkVersion="{{ args.min_sdk_version }}" android:targetSdkVersion="{{ android_api }}" />
<!-- OpenGL ES 2.0 -->
<uses-feature android:glEsVersion="0x00020000" />
<!-- Allow writing to external storage -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
{% for perm in args.permissions %}
{% if '.' in perm %}
<uses-permission android:name="{{ perm }}" />
{% else %}
<uses-permission android:name="android.permission.{{ perm }}" />
{% endif %}
{% endfor %}
{% if args.wakelock %}
<uses-permission android:name="android.permission.WAKE_LOCK" />
{% endif %}
{% if args.billing_pubkey %}
<uses-permission android:name="com.android.vending.BILLING" />
{% endif %}
{{ args.extra_manifest_xml }}
<!-- Create a Java class extending SDLActivity and place it in a
directory under src matching the package, e.g.
src/com/gamemaker/game/MyGame.java
then replace "SDLActivity" with the name of your class (e.g. "MyGame")
in the XML below.
An example Java class can be found in README-android.txt
-->
<application android:label="@string/app_name"
{% if debug %}android:debuggable="true"{% endif %}
android:icon="@mipmap/icon"
android:allowBackup="{{ args.allow_backup }}"
{% if args.backup_rules %}android:fullBackupContent="@xml/{{ args.backup_rules }}"{% endif %}
{{ args.extra_manifest_application_arguments }}
android:theme="{{args.android_apptheme}}{% if not args.window %}.Fullscreen{% endif %}"
android:hardwareAccelerated="true"
android:extractNativeLibs="true" >
{% for l in args.android_used_libs %}
<uses-library android:name="{{ l }}" />
{% endfor %}
{% for m in args.meta_data %}
<meta-data android:name="{{ m.split('=', 1)[0] }}" android:value="{{ m.split('=', 1)[-1] }}"/>{% endfor %}
<meta-data android:name="wakelock" android:value="{% if args.wakelock %}1{% else %}0{% endif %}"/>
<activity android:name="{{args.android_entrypoint}}"
android:label="@string/app_name"
android:configChanges="mcc|mnc|locale|touchscreen|keyboard|keyboardHidden|navigation|orientation|screenLayout|fontScale|uiMode{% if args.min_sdk_version >= 8 %}|uiMode{% endif %}{% if args.min_sdk_version >= 13 %}|screenSize|smallestScreenSize{% endif %}{% if args.min_sdk_version >= 17 %}|layoutDirection{% endif %}{% if args.min_sdk_version >= 24 %}|density{% endif %}"
android:screenOrientation="{{ args.orientation }}"
android:exported="true"
{% if args.activity_launch_mode %}
android:launchMode="{{ args.activity_launch_mode }}"
{% endif %}
>
{% if args.launcher %}
<intent-filter>
<action android:name="org.kivy.LAUNCH" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="{{ url_scheme }}" />
</intent-filter>
{% else %}
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
{% endif %}
{%- if args.intent_filters -%}
{{- args.intent_filters -}}
{%- endif -%}
</activity>
{% if args.launcher %}
<activity android:name="org.kivy.android.launcher.ProjectChooser"
android:icon="@mipmap/icon"
android:label="@string/app_name"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
{% endif %}
{% if service or args.launcher %}
<service android:name="{{ args.service_class_name }}"
android:process=":pythonservice" />
{% endif %}
{% for name in service_names %}
<service android:name="{{ args.package }}.Service{{ name|capitalize }}"
android:process=":service_{{ name }}" />
{% endfor %}
{% for name in native_services %}
<service android:name="{{ name }}" />
{% endfor %}
{% if args.billing_pubkey %}
<service android:name="org.kivy.android.billing.BillingReceiver"
android:process=":pythonbilling" />
<receiver android:name="org.kivy.android.billing.BillingReceiver"
android:process=":pythonbillingreceiver"
android:exported="false">
<intent-filter>
<action android:name="com.android.vending.billing.IN_APP_NOTIFY" />
<action android:name="com.android.vending.billing.RESPONSE_CODE" />
<action android:name="com.android.vending.billing.PURCHASE_STATE_CHANGED" />
</intent-filter>
</receiver>
{% endif %}
{% for a in args.add_activity %}
<activity android:name="{{ a }}"></activity>
{% endfor %}
</application>
</manifest>
Это файл шаблона который берется за основу создаваемого buildozer AndroidManifest.xml. При первом просмотре, сразу обратил внимания на такие странные вставки как например эта:
{{ args.extra_manifest_application_arguments }}
Их значения объясню дальше.
Когда этот файл был найден стало понятно, что делать. Правда на его поиск и понимание что искать ушло время.
Все возможные AndroidManifest-ы в папке проекта, их и анализировал.
Теперь нужно добавить внутрь тэга application наш тэг receiver в котором будет прописано имя нашего обработчика сигналов, и какие сигналы он принимает:
<receiver android:name=".MyBroadcastReceiver" android:enabled="true" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON" />
<action android:name="android.intent.action.MAIN" />
<action android:name="android.intent.action.DELETE" />
</intent-filter>
</receiver>
После выполнить:
buildozer android clean
buildozer android debug
Если не сделать clean, то как оказалось за основу генерации берется не файл:
/kivy_service_test/.buildozer/android/platform/python-for-android/pythonforandroid/bootstraps/sdl2/build/templates/AndroidManifest.tmpl.xml
а файл:
./kivy_service_test/.buildozer/android/platform/build-arm64-v8a/dists/kivy_service_test/templates/AndroidManifest.tmpl.xml
Который копируется туда при первой сборке:
buildozer android debug
И далее он не будет обновляться, пока не будет выполнена очистка проекта.
Первая сборка необходима для скачивания python-for-android, она копирует исходные шаблонные файлы для сборки под каждую архитектуру под которую идет сборка проекта. В данном случае под arm64-v8a, которая указывается в buildozer.spec:
android.archs = arm64-v8a
Поэтому его можно руками удалить и скопировать нужный, или выполнить очистку проекта.
Правильный
С помощью файла buildozer.spec можно вносить некоторые правки в AndroidManifest.xml. Но вот ту, что нужна для автозагрузки нельзя. При анализе default.spec обнаружил следующие параметры настройки:
# (str) Extra xml to write directly inside the <manifest> element of AndroidManifest.xml
# use that parameter to provide a filename from where to load your custom XML code
android.extra_manifest_xml = ./src/android/extra_manifest.xml
# (str) Extra xml to write directly inside the <manifest><application> tag of AndroidManifest.xml
# use that parameter to provide a filename from where to load your custom XML arguments:
android.extra_manifest_application_arguments = ./src/android/extra_manifest_application_arguments.xml
И теперь вернемся к вставке из AndroidManifest.tmpl.xml
{{ args.extra_manifest_application_arguments }}
Теперь стало понятно куда будут подставлены файлы xml из секции конфига. Содержимое этих файлов будет автоматически обновляться в сборочном AndroidManifest.xml при каждой сборке.
Поэтому я добавил свою секцию в AndroidManifest.tmpl.xml:
{{ args.extra_manifest_application }}
А так же пришлось внести правки в исходники: buildozer, python-for-android.
После этого в моем buildozer.spec стала доступна новая настройка:
android.extra_manifest_application = %(source.dir)s/xml/receivers.xml
Которая в нужное место AndroidManifest.xml подставляет обработчик сигналов описанных в receivers.xml
Мои pull request разработчики на текущий момент не одобрили, поэтому на рабочей машине править нужно в следующих местах:
- buildozer — /usr/local/lib/python3.8/dist-packages/buildozer (версия python индивидуальна)
- python-for-android — ./kivy_service_test/.buildozer/android/platform/python-for-android/
Receiver
receiver.xml
<receiver android:name=".MyBroadcastReceiver" android:enabled="true" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON" />
<action android:name="android.intent.action.MAIN" />
<action android:name="android.intent.action.DELETE" />
</intent-filter>
</receiver>
Его содержимое вставляется в AndroidManifest.xml
<application ...>
<receiver> ... </receiver>
</application>
MyBroadcastReceiver имя класса принимающего сигналы, он определен в MyBroadcastReceiver.java
package com.heattheatr.kivy_service_test;
import android.content.BroadcastReceiver;
import android.content.Intent;
import android.content.Context;
import org.kivy.android.PythonActivity;
import java.lang.reflect.Method;
import com.heattheatr.kivy_service_test.ServiceTest;
public class MyBroadcastReceiver extends BroadcastReceiver {
public MyBroadcastReceiver() {
}
// Запуск приложения.
public void start_app(Context context, Intent intent) {
Intent ix = new Intent(context, PythonActivity.class);
ix.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(ix);
}
// Запуск сервиса.
public void service_start(Context context, Intent intent) {
String package_root = context.getFilesDir().getAbsolutePath();
String app_root = package_root + "/app";
Intent ix = new Intent(context, ServiceTest.class);
ix.putExtra("androidPrivate", package_root);
ix.putExtra("androidArgument", app_root);
ix.putExtra("serviceEntrypoint", "service.py");
ix.putExtra("pythonName", "test");
ix.putExtra("pythonHome", app_root);
ix.putExtra("pythonPath", package_root);
ix.putExtra("serviceStartAsForeground", "true");
ix.putExtra("serviceTitle", "ServiceTest");
ix.putExtra("serviceDescription", "ServiceTest");
ix.putExtra("pythonServiceArgument", app_root + ":" + app_root + "/lib");
ix.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startService(ix);
}
public void service_stop(Context context, Intent intent) {
Intent intent_stop = new Intent(context, ServiceTest.class);
context.stopService(intent_stop);
}
// Обработчик сигналов.
public void onReceive(Context context, Intent intent) {
switch (intent.getAction()) {
case Intent.ACTION_BOOT_COMPLETED:
System.out.println("python MyBroadcastReceiver.java
MyBroadcastReceiver.class onReceive.method: ACTION_BOOT_COMPLETED");
this.service_start(context, intent);
break;
case Intent.ACTION_DELETE:
System.out.println("python MyBroadcastReceiver.java
MyBroadcastReceiver.class onReceive.method: ACTION_DELETE");
this.service_stop(context, intent);
break;
case Intent.ACTION_MAIN:
System.out.println("python MyBroadcastReceiver.java
MyBroadcastReceiver.class onReceive.method: ACTION_MAIN");
this.start_app(context, intent);
break;
default:
break;
}
}
}
Класс содержит четыре функции: запуск/остановка сервиса, запуск графического приложения и обработка сигналов (onReceive наследуемый метод от класса BroadcastReceiver).
Особую сложность у меня вызвала реализация метода service_start. Т.к. необходимые Intent для запуска сервиса были изменены. Актуальные нашел здесь PythonActivity.java, метод _do_start_service().
Service
Особо выделю ServiceTest, это класс нашего сервиса service.py. Приведенного из python к java.
#!/usr/bin/python3
#-*- coding: utf-8 -*-
import os
from time import sleep
from kivy.utils import platform
from jnius import cast
from jnius import autoclass
# Подключение классов Android
if platform == 'android':
PythonService = autoclass('org.kivy.android.PythonService')
# Автоперезапуск упавшего сервиса
PythonService.mService.setAutoRestartService(True)
CurrentActivityService = cast("android.app.Service", PythonService.mService)
ContextService = cast('android.content.Context', CurrentActivityService.getApplicationContext())
ContextWrapperService = cast('android.content.ContextWrapper', CurrentActivityService.getApplicationContext())
Manager = CurrentActivityService.getPackageManager()
Intent = autoclass('android.content.Intent')
def application_start():
pm = CurrentActivityService.getPackageManager()
ix = pm.getLaunchIntentForPackage(CurrentActivityService.getPackageName())
ix.setAction(Intent.ACTION_VIEW)
ix.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
CurrentActivityService.startActivity(ix)
while True:
print("python service running.....", CurrentActivityService.getPackageName(), os.getpid())
sleep(10)
else:
def application_start():
pass
while True:
print("python service running.....", os.getpid())
sleep(10)
Преобразовывается service.py в ServiceTest следующим образов, в buildozer.spec задается настройка:
# NAME_SERVICE:PATH_TO_PY
# (list) List of service to declare
services = Test:./service.py:foreground
Согласно которой имя нашего файла сервиса будет Service + Test. Почему не Test?, а потому что так захотелось разработчикам. Они решили к любому имени добавлять префикс Service.
Путь до service.py нельзя задавать через %(source.dir)s
, т.к. это будет путь до файла на компьютере, и соответственно на телефоне данный файл будет лежать по другому пути.
Перезапуск сервиса в случае его завершения задается:
# Автоперезапуск упавшего сервиса
PythonService.mService.setAutoRestartService(True)
Main
Так же сервис можно запускать/останавливать непосредственно из python:
#!/usr/bin/python3
#-*- coding: utf-8 -*-
import kivy
kivy.require("2.1.0")
from kivy.app import App
from kivy.uix.button import Button
from kivy.utils import platform
import jnius
from jnius import cast
from jnius import autoclass
# Подключение классов Android
if platform == 'android':
# Подключение класса System
System = autoclass('java.lang.System')
PythonActivity = autoclass('org.kivy.android.PythonActivity')
CurrentActivity = cast('android.app.Activity', PythonActivity.mActivity)
# Класс графики, который создает кнопку для выхода из приложения.
class ButtonApp(App):
def build(self):
# use a (r, g, b, a) tuple
btn = Button(text ="Push Me !",
font_size ="20sp",
background_color = (1, 1, 1, 1),
color = (1, 1, 1, 1),
size_hint = (.2, .1),
pos_hint = {'x':.4, 'y':.45})
# bind() use to bind the button to function callback
btn.bind(on_press = self.callback)
return btn
def on_start(self):
self.service = None
# При старте приложения запускаем сервис.
self.service_start()
# callback function tells when button pressed
def callback(self, event):
if platform == 'android':
CurrentActivity.finishAndRemoveTask()
System.exit(0)
else :
exit()
# функция запуска сервиса
def service_start(self):
if platform == 'android':
self.service = autoclass(CurrentActivity.getPackageName() + ".ServiceTest")
self.service.start(CurrentActivity, "")
# функция остановки сервиса
def service_stop(self):
if self.service :
if platform == 'android':
self.service.stop(CurrentActivity)
##
# Старт.
##
if __name__ == "__main__":
# Отрисовка графики приложения
ButtonApp().run()
В Android стоит защита которая не даст запустить сервис/приложение если они уже запущены, что упрощает жизнь.
Что бы все заработало, необходимо после установки/обновления запустить новое приложение один раз. Т.к. в Android стоит защита, он не будет запускать новоустановленное в целях безопасности.
Отладка
После установки подключаемся к телефону:
adb logcat | egrep "python|Test|test"
И видим результат работы:
11-08 18:34:01.214 12305 12318 I Test : Android kivy bootstrap done. __name__ is __main__
11-08 18:34:01.214 12305 12318 I python : AND: Ran string
11-08 18:34:01.214 12305 12318 I python : Run user program, change dir and execute entrypoint
11-08 18:34:01.630 12305 12318 I Test : [INFO ] [Logger ] Record log in /data/user/0/com.heattheatr.kivy_service_test/files/app/.kivy/logs/kivy_22-11-08_0.txt
11-08 18:34:01.631 12305 12318 I Test : [INFO ] [Kivy ] v2.1.0
11-08 18:34:01.632 12305 12318 I Test : [INFO ] [Kivy ] Installed at "/data/user/0/com.heattheatr.kivy_service_test/files/app/_python_bundle/site-packages/kivy/__init__.pyc"
11-08 18:34:01.633 12305 12318 I Test : [INFO ] [Python ] v3.9.9 (main, Nov 7 2022, 09:58:48)
11-08 18:34:01.633 12305 12318 I Test : [Clang 12.0.8 (https://android.googlesource.com/toolchain/llvm-project c935d99d
11-08 18:34:01.634 12305 12318 I Test : [INFO ] [Python ] Interpreter at ""
11-08 18:34:01.636 12305 12318 I Test : [INFO ] [Logger ] Purge log fired. Processing...
11-08 18:34:01.638 12305 12318 I Test : [INFO ] [Logger ] Purge finished!
11-08 18:34:04.514 12305 12318 I Test : python service running..... com.heattheatr.kivy_service_test 12305
11-08 18:34:14.524 12305 12318 I Test : python service running..... com.heattheatr.kivy_service_test 12305
Из другой консоли можем посылать сигналы своему приложению:
adb shell
am broadcast -a android.intent.action.BOOT_COMPLETED com.heattheatr.kivy_service_test
am broadcast -a android.intent.action.DELETE com.heattheatr.kivy_service_test
am broadcast -a android.intent.action.MAIN com.heattheatr.kivy_service_test
Вопросы
То с чем не смог разобраться, и хочу спросить у знающих людей.
- Закрытие приложение приводит к тому что сервис тоже закрывается (обошел это костылями по автоматическому перезапуску). Как не закрывать сервис при закрытии приложения?
Спасибо за внимание.