python

Starting Kivy App and Service on bootup on Android

  • среда, 9 ноября 2022 г. в 00:43:58
https://habr.com/ru/post/694906/
  • Python
  • Java
  • Разработка под Android


main

Как запускать приложение и сервис написанные на python под android при запуске устройства. Что бы это сделать придется разбираться как работает buildozer и pythonforandroid. Т.к. на текущий момент сделать это по человечески не представлялось возможным, из-за того что разработчики 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/.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 }}

Их значения объясню дальше.


Когда этот файл был найден стало понятно что делать. Правда на его поиск и понимание что искать ушло время.



Теперь нужно добавить внутрь тэга 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. И как видно его расположение зависит непосредственно от архитектуры под которую мы собираем и указываем в 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() {

    }

    // pythonforandroid/bootstraps/webview/build/src/main/java/org/kivy/android/PythonActivity.java
    // method _do_start_service()

    // Запуск приложения.
    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.


#!/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

Вопросы


То с чем не смог разобраться, и хочу спросить у знающих людей.


  • Закрытие приложение приводит к тому что сервис тоже закрывается (обошел это костылями по автоматическому перезапуску). Как не закрывать сервис при закрытии приложения?

Спасибо за внимание.


Ссылки