Python으로 안드로이드 앱 만들기

2019년 블랙프라이데이 주간에 Amazon Fire 10 HD를 구입했다. 생각보다 요긴하게 쓰고 있는데 Termux라는 앱을 설치하면 기본적인 Linux 환경을 사용할 수 있기 때문이다. Tmux, Vim, Python/Ipython 등을 설치해 사용하고 SSH/Mosh 등의 도구로 원격 접속하여 작업할 수 있다. 그렇게 사용해오다 잠금화면에 자꾸 나오는 광고가 눈에 거슬려 루팅 없이 어떻게 없앨 수 있나 찾아봤다.

찾아보니.. 글로벌 세팅 값에 LOCKSCREEN_AD_ENABLED 라는 값을 1에서 0으로 바꾸면 비활성화 시킬 수 있다고 하다. 글로벌/시스템 세팅을 바꿀 수 있는 앱을 설치하고 adb 를 이용해 WRITE_SECURE_SETTINGS 권한을 부여했다. 그런 다음, LOCKSCREEN_AD_ENABLED를 0으로 바꾸니 정말 잠금화면 광고가 사라졌다. 하지만 문제가 있었는데 주기적으로 다른 빌트인 아마존 앱에서 이 세팅값을 다시 1로 바꾼다는 것이다. 이를 막기 위해선 더 빈번한 주기로 해당 세팅값을 0으로 바꾸면 될 것 같아 방법을 고민했다.

다른 앱의 도움 없이 진행할 수 있는 방법은.. 노트북으로 태블릿에 연결한 다음, adb 원격 디버깅을 켠다. 그럼 특정 TCP 포트가 열리고 여길 통해 adb를 사용할 수 있는데.. 이 상태에서 adb 명령으로 세팅값을 바꾸는 것이다. ARM 프로세서에서 동작할 수 있는 adb를 구해 태블릿에 내려받았고 Termux에서 주기적으로 세팅 바꾸는 명령을 수행했다. 하지만.. 어느 정도 시간이 지나자 adb 원격 디버깅이 해제되며 더 이상 세팅값을 바꿀 수 없었다.

결국 네이티브 앱을 만들어야 했다. 이왕이면 편하게 만들고 싶어서 Java가 아닌 다른 언어로 작성할 수 있는 크로스플랫폼 앱 개발도구를 찾아봤다. React Native, Meteor 같은 도구는 JS로 작성할 수 있고 UI도 Vue/React 등을 활용할 수 있는데 지금 내게 더 익숙한 언어는 Python이라 Kivy + Buildozer 조합을 찾았다. Kivy는 Python으로 데스크탑/모바일 앱을 만드는 크로스플랫폼 프레임워크이고 Buildozer를 명령행 도구인데 타겟 플랫폼으로의 빌드를 도와준다.

앱에서 해야하는 일은 다음과 같은데.. 배경에서 떠 있는 서비스가 존재해야 하며 이 서비스는 다른 앱에서 오는 요청에 따라 정해진 기능을 수행한다. 일단 필요한 기능은 글로벌 세팅의 특정 키 값을 바꾸는 것이다. 요청을 받는 프로토콜로 처음엔 Android Intent를 생각했는데 굳이 그럴 것 없이 그냥 웹서버를 하나 띄우기로 바꾸었다. Termux에서 curl 명령으로 쉽게 웹서버에 요청을 보낼 수 있다. HTTP 요청으로 command (사용할 기능), parameters (해당 기능에 필요한 인자들) 을 보내면 그에 걸맞는 작업을 수행하는 식이다.

먼저 buildozer를 설치하고, https://buildozer.readthedocs.io/en/latest/
작업디렉토리를 하나 만든 다음, 그 안에서 아래 명령을 수행한다.

$ buildozer init

./buildozer.spec 파일이 생성되고 그 안에서 title, package.name, package.domain 등 필요 정보를 설정한다. 앱에서 웹서버를 띄우고 세팅을 바꿔야해서 아래 라인을 추가했다.

경축! 아무것도 안하여 에스천사게임즈가 새로운 모습으로 재오픈 하였습니다.
어린이용이며, 설치가 필요없는 브라우저 게임입니다.
https://s1004games.com

android.permissions = INTERNET,ACCESS_NETWORK_STATE,WAKE_LOCK,WRITE_SETTINGS,WRITE_SECURE_SETTINGS

그리고 파일 두개를 만들었다. main.py는 앱이 시작될 때 실행되는 스크립트이고 ./service/main.py는 서비스에서 사용할 스크립트다. main.py에는 서비스 실행하는 코드가 들어가고 ./service/main.py에는 웹서버 코드가 들어간다

# ./main.py
from kivy.app import App

from kivy.utils import platform
from kivy.uix.button import Button
from kivy.uix.boxlayout import BoxLayout

class FiredApp(App):
    def build(self):

        if platform=="android":
            # start service
            from android import AndroidService
            service = AndroidService('Fired Service', 'running')
            service.start('service started')
            self.service = service

        self.btn = Button(text='Button',
                size_hint=(.90, .10),
                pos=(5, 5),
                font_size=50)
        self.btn.bind(on_press=self.btn_on_press)
        l = BoxLayout()
        l.add_widget(self.btn)
        return l

    def btn_on_press(self, *args):
        self.btn.text = "the event was called"

if __name__ == '__main__':
    if platform=="android":
        app = FiredApp()
        app.run()
# ./service/main.py
from http.server import HTTPServer, BaseHTTPRequestHandler
import traceback
import json

#import jsonschemaz
import jnius


class RequestHandler(BaseHTTPRequestHandler):
    def _get_context(self):
        PythonService = jnius.autoclass('org.kivy.android.PythonService')
        service = PythonService.mService
        return service.getApplication().getApplicationContext()

    def _read_setting(self, key, target):
        context = self._get_context()
        cr = context.getContentResolver()
        Settings = jnius.autoclass('android.provider.Settings${}'.format(target))
        value = Settings.getInt(cr, key)
        return value

    def _write_setting(self, key, value, target):
        context = self._get_context()
        cr = context.getContentResolver()
        Settings = jnius.autoclass('android.provider.Settings${}'.format(target))
        Settings.putInt(cr, key, value)

    def send_response(self, code, message=None):
        self.log_request(code)
        self.send_response_only(code)
        self.send_header('Server', 'Fired Service')
        self.send_header('Date', self.date_time_string())
        self.end_headers()

    def do_GET(self):
        """ response for a GET request """
        self.send_response(200)

        response = {
            'success': True,
            'data': 'Fired Service',
        }
        response_str = json.dumps(response)
        self.wfile.write(bytes(response_str, 'utf-8'))

    def do_POST(self):
        """ response for a POST """
        self.send_response(200)

        content_length = int(self.headers['Content-Length'])
        content = self.rfile.read(content_length).decode('utf-8')
        payload = json.loads(content)

        payload_schema = {
            'type' : 'object',
            'properties': {
                'command': {
                    'type': 'string',
                    'enum': ['ReadGlobalSetting', 'WriteGlobalSetting'],
                },
                'parameters': {'type': 'object'},
            },
            'required': ['command', 'parameters'],
        }

        response = {
            'success': True,
        }

        try:
            #jsonschema.validate(payload, payload_schema)

            if payload['command'] == 'ReadGlobalSetting':
                key = payload['parameters']['key']
                value = self._read_setting(key, target='Global')
                response['data'] = dict([(key, value)])

            elif payload['command'] == 'WriteGlobalSetting':
                key = payload['parameters']['key']
                value = payload['parameters']['value']
                self._write_setting(key, value, target='Global')

                value = self._read_setting(key, target='Global')
                response['data'] = dict([(key, value)])

            elif payload['command'] == 'ReadSystemSetting':
                key = payload['parameters']['key']
                value = self._read_setting(key, target='System')
                response['data'] = dict([(key, value)])

            elif payload['command'] == 'WriteSystemSetting':
                key = payload['parameters']['key']
                value = payload['parameters']['value']
                self._write_setting(key, value, target='System')

                value = self._read_setting(key, target='System')
                response['data'] = dict([(key, value)])

        except Exception as exc:
            traceback.print_exc()
            response['success'] = False
            response['reason'] = str(exc)

        response_str = json.dumps(response)
        self.wfile.write(bytes(response_str, 'utf-8'))


if __name__ == '__main__':
    host_address, host_port = '', 9999
    server_address = (host_address, host_port)
    httpd = HTTPServer(server_address, RequestHandler)
    httpd.serve_forever()

코드 작성이 완료되었으면 아래 명령으로 apk 파일을 만들 수 있다

$ buildozer -v android debug
# USB로 안드로이드 장치에 연결된 상태라면 아래 명령으로 앱 빌드, 설치 후 실행
$ buildozer -v android debug deploy run 

앱을 실행하면 서비스(웹서버)가 실행되고 9999번 포트를 통해 통신할 수 있다. 예를 들어, 내가 필요했던 기능인 LOCKSCREEN_AD_ENABLED를 0으로 설정하는 걸 하려면 아래 명령을 보내면 된다.

$ cat suppress-lockscreen-ad.sh
#!/bin/bash
curl -X POST --data '{"command": "WriteGlobalSetting", "parameters": {"key": "LOCKSCREEN_AD_ENABLED", "value": 0}}' http://localhost:9999

# 10초 마다 세팅값을 바꾸고 싶으면 간단히 쉘 기능으로 반복하면 된다
$ while true; do
    date;
    ./suppress-lockscreen-ad.sh;
    sleep 10;
done

실행시키고 하루 정도 지나 다시 확인했는데 잘 작동하고 있었다. 이제 잠금화면 광고를 안 봐도 된다. 사실 요즘 같은 시대엔 웹앱 등으로 많은 것들을 할 수 있어 네이티브 앱을 만들 일은 별로 없지만 혹시 필요해지면 이 방법으로 만들어 볼 수 있겠다. 아, 그리고 추가 기능을 몇개 넣고 싶은데 지금 생각나는 건, 화면 밝기를 조절하는 기능이다. 화면 터치하기 귀찮을 때가 있는데 키보드 입력 혹은 터미널 명령 실행으로 화면 밝기를 조절할 수 있으면 좋겠다.

# Troubleshooting 기록

javax.bind.xml 이었나 관련 에러가 발생하면 아래 명령으로 패키지 설치
$ sudo apt-get install libjaxb-java

sdkmanager 등 관련 스크립트에 CLASSPATH 추가 (혹은 전역으로?)
CLASSPATH=/usr/share/java/jaxb-runtime.jar:$CLASSPATH

그리고 Gradle 사용에서 에러가 발생하는 경우,
Gradle: Could not determine java version from '11.0.6'

gradle/wrapper/gradle-wrapper.properties 에서 아래 값으로 대체,
distributionUrl=https\://services.gradle.org/distributions/gradle-5.1.1-all.zip
 

[출처] https://minhwan.kim/python-android-app-dev-with-kivy-and-buildozer/

 

본 웹사이트는 광고를 포함하고 있습니다.
광고 클릭에서 발생하는 수익금은 모두 웹사이트 서버의 유지 및 관리, 그리고 기술 콘텐츠 향상을 위해 쓰여집니다.
번호 제목 글쓴이 날짜 조회 수
101 나인패치(Nine Patch) 이미지란? file 졸리운_곰 2020.10.10 56
» Python으로 안드로이드 앱 만들기 졸리운_곰 2020.09.30 839
99 [Android] Fragment 를 이용한 탭 만들기 file 졸리운_곰 2020.09.19 52
98 빠르게배우는 안드로이드 Fragment-4(Fragment간 에 데이터전달) file 졸리운_곰 2020.09.19 36
97 빠르게배우는 안드로이드 Fragment-3(Fragment기초 실습) file 졸리운_곰 2020.09.19 58
96 빠르게배우는 안드로이드 Fragment-2(Activity-> Fragment로 쉽게 변경) 졸리운_곰 2020.09.19 38
95 빠르게배우는 안드로이드 Fragment -1 (배경) file 졸리운_곰 2020.09.19 38
94 안드로이드 스튜디오 - 새로 생성한 Activity Class를 쉽게 Manifest에 등록하기. file 졸리운_곰 2020.08.08 52
93 Android Studio Build시 failed linking references 해결방법 file 졸리운_곰 2020.05.05 517
92 [안드로이드 스튜디오] COULD NOT FIND COM.ANDROID.TOOLS.BUILD:GRADLE:3.0.0-BETA6 file 졸리운_곰 2020.05.05 43
91 Android Sync SQLite Database with Server using PHP and MySQL file 졸리운_곰 2019.02.25 7406
90 안드로이드의 MVC, MVP, MVVM 종합 안내서 file 졸리운_곰 2019.01.06 256
89 Getting Started: WebView-based Applications for Web Developers file 졸리운_곰 2018.09.03 264
88 App Inventor - MySQL interface 앱 인벤터 mysql 연결 file 졸리운_곰 2018.04.07 2330
87 Connect App Inventor to MySQL Database 앱 인벤터와 mysql 데이터베이스 연결 file 졸리운_곰 2018.04.07 5391
86 Create an API (PHP) 앱 인벤터와 php 통합 file 졸리운_곰 2018.04.07 493
85 Android WebView javascriptInterface 사용하기 file 졸리운_곰 2018.03.26 497
84 Using the WebViewer Control in App Inventor 앱인터에서 웹뷰 컨트롤 사용 file 졸리운_곰 2018.03.24 390
83 WebView Javascript Processor for App Inventor 앱 인벤터 웹뷰 자바스크립트 인터페이스 file 졸리운_곰 2018.03.24 365
82 android webview로 javascript 호출 및 이벤트 받기(연동하기) file 졸리운_곰 2018.03.19 1299
대표 김성준 주소 : 경기 용인 분당수지 U타워 등록번호 : 142-07-27414
통신판매업 신고 : 제2012-용인수지-0185호 출판업 신고 : 수지구청 제 123호 개인정보보호최고책임자 : 김성준 sjkim70@stechstar.com
대표전화 : 010-4589-2193 [fax] 02-6280-1294 COPYRIGHT(C) stechstar.com ALL RIGHTS RESERVED