Starting Kivy App and Service on bootup on 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

Вопросы

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

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

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

Ссылки


ссылка на оригинал статьи https://habr.com/ru/post/694906/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *