Как стать автором
Обновить

Android. Starting Kivy App and Service on bootup

Время на прочтение15 мин
Количество просмотров3.4K
main

Как запускать приложение или сервис написанные на 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

Поэтому можно обновить только AndroidManifest.tmpl.xml, а не выполнять очистку проекта.


rm ./kivy_service_test/.buildozer/android/platform/build-arm64-v8a/dists/kivy_service_test/templates/AndroidManifest.tmpl.xml
cp ./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/

Правильный


С помощью файла 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 при сборке.


python-for-android


Добавляем свою секцию в AndroidManifest.tmpl.xml в следующих файлах:


{{ args.extra_manifest_application_xml }}

  • pythonforandroid/bootstraps/sdl2/build/templates/AndroidManifest.tmpl.xml
  • pythonforandroid/bootstraps/service_library/build/templates/AndroidManifest.tmpl.xml
  • pythonforandroid/bootstraps/service_only/build/templates/AndroidManifest.tmpl.xml
  • pythonforandroid/bootstraps/webview/build/templates/AndroidManifest.tmpl.xml

Так же в python-for-android добавляем в файл pythonforandroid/bootstraps/common/build/build.py перед строкой ap.add_argument('--extra-manifest-application-arguments':


ap.add_argument('--extra-manifest-application-xml', default='',
    help='Extra xml to write directly inside the <application> element of'
    'AndroidManifest.xml')

buildozer


Добавляем в файл buildozer/targets/android.py перед строкой # support for extra-manifest-application-arguments:


# support for extra-manifest-application
extra_manifest_application_xml = self.buildozer.config.getdefault(
    'app', 'android.extra_manifest_application_xml', '')
if extra_manifest_application_xml:
    cmd.append('--extra-manifest-application-xml= {}'.format(open(extra_manifest_application_xml, 'rt').read().replace('"', '\\"')))

После этого в buildozer.spec доступна новая настройка:


android.extra_manifest_application_xml = %(source.dir)s/xml/receivers.xml

Которая в нужное место AndroidManifest.xml подставляет обработчик сигналов описанных в receivers.xml


Правки в исходниках: buildozer, python-for-android.
Мои 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 + ":"  + "/lib");
        ix.putExtra("smallIconName", "");
        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, в 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


Так же сервис можно запускать/останавливать из графического приложения:


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

Вопрос


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


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

Ответ


  • Знающие люди нашлись.
    По умолчанию сервис запускается с флагом START_NOT_STICKY, его нужно исправить на START_STICKY.

Указываем в buildozer.spec:


services = Test:./service.py:foreground:sticky

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


Ссылки


Теги:
Хабы:
Всего голосов 7: ↑6 и ↓1+5
Комментарии0

Публикации

Истории

Работа

Data Scientist
61 вакансия
Java разработчик
350 вакансий
Python разработчик
137 вакансий

Ближайшие события