Skip to content

Commit

Permalink
add utilities for launching startup scripts
Browse files Browse the repository at this point in the history
  • Loading branch information
b3b committed Feb 13, 2021
1 parent 5195abb commit 8ae60e2
Show file tree
Hide file tree
Showing 7 changed files with 241 additions and 1 deletion.
1 change: 1 addition & 0 deletions buildozer.spec
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ android.permissions =


android.wakelock=True
android.manifest.launch_mode = singleTask

[buildozer]
log_level = 2
Expand Down
101 changes: 101 additions & 0 deletions pythonhere/android_here.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"""Android specific functions."""
# pylint: disable=invalid-name,import-error,import-outside-toplevel
from pathlib import Path
from typing import Optional
import uuid

from android import activity as android_activity
from jnius import autoclass, cast
from kivy.logger import Logger


Context = autoclass("android.content.Context")
Icon = autoclass("android.graphics.drawable.Icon")
Intent = autoclass("android.content.Intent")
PythonActivity = autoclass("org.kivy.android.PythonActivity")
ShortcutInfoBuilder = autoclass("android.content.pm.ShortcutInfo$Builder")
System = autoclass("java.lang.System")
Uri = autoclass("android.net.Uri")


def get_current_intent() -> Intent:
"""Return the intent that started Python activity."""
return PythonActivity.mActivity.getIntent()


def get_startup_script(intent: None = None) -> Optional[str]:
"""Return script entrypoint that was passed to a given, or current, intent."""
if not intent:
intent = get_current_intent()
data = intent.getData()
return data and data.toString()


def restart_app(script: str = None):
"""Restart app, with a script as a starting point if provided."""
Logger.info("PythonHere: restart requested with a script: %s", script)
activity = PythonActivity.mActivity
intent = Intent(activity.getApplicationContext(), PythonActivity)
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)

if script:
intent.setData(Uri.parse(script))

activity.startActivity(intent)
System.exit(0)


def bind_run_script_on_new_intent():
"""Add handler for new intent event:
restart app with entrypoint of a new intent.
"""

def on_new_intent(intent):
Logger.info("PythonHere: on_new_intent")
restart_app(get_startup_script(intent))
Logger.error("PythonHere: app was not restarted")

android_activity.bind(on_new_intent=on_new_intent)


def create_shortcut_icon() -> Icon:
"""Create icon to use for a shurtcut."""
activity = PythonActivity.mActivity
Drawable = autoclass("{}.R$drawable".format(activity.getPackageName()))
context = cast("android.content.Context", activity.getApplicationContext())
return Icon.createWithResource(context, Drawable.icon)


def resolve_script_path(script: str) -> str:
"""Resolve path against upload directory."""
from kivy.app import App

if script.startswith("/"):
path = Path(script)
else:
app = App.get_running_app()
path = Path(app.upload_dir) / script
return str(path.resolve(strict=True))


def pin_shortcut(script: str, label: str):
"""Request a pinned shortcut creation to run a Python script."""
activity = PythonActivity.mActivity
context = cast("android.content.Context", activity.getApplicationContext())

intent = Intent(activity.getApplicationContext(), PythonActivity)
intent.setAction(Intent.ACTION_MAIN)
intent.setData(Uri.parse(resolve_script_path(script)))

shortcut = (
ShortcutInfoBuilder(context, f"pythonhere-{uuid.uuid4().hex}")
.setShortLabel(label)
.setLongLabel(label)
.setIntent(intent)
.setIcon(create_shortcut_icon())
.build()
)

manager = activity.getSystemService(Context.SHORTCUT_SERVICE)
manager.requestPinShortcut(shortcut, None)
45 changes: 45 additions & 0 deletions pythonhere/launcher_here.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""Utilities for launching scripts."""
import os
from pathlib import Path
import runpy
import sys


from kivy.logger import Logger
from kivy import platform


def run_script(script: str):
"""Execute given script."""
Logger.info("PythonHere: Run script %s", script)
try:
path = Path(script).resolve(strict=True)
except FileNotFoundError:
Logger.error("Script not found: %s", script)
return

original_cwd = str(Path.cwd())
original_sys_path = sys.path[:]
try:
script_dir = path.parent
os.chdir(str(script_dir))
sys.path.insert(0, str(script_dir))
runpy.run_path(str(path), run_name="__main__")
finally:
os.chdir(original_cwd)
sys.path = original_sys_path


def try_startup_script():
"""Execute startup script, if it was passed to app."""
if platform != "android":
return
import android_here # pylint: disable=import-outside-toplevel

try:
android_here.bind_run_script_on_new_intent()
script = android_here.get_startup_script()
if script:
run_script(script)
except Exception:
Logger.exception("PythonHere: Error while starting script")
6 changes: 6 additions & 0 deletions pythonhere/main.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
"""PythonHere app."""
# pylint: disable=wrong-import-order,wrong-import-position

from launcher_here import try_startup_script

try_startup_script() # run script entrypoint, if it was passed

import asyncio
import os
from pathlib import Path
Expand Down
17 changes: 16 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,13 @@ def app_config():


@pytest.fixture
async def app_instance(mocker, capfd, app_config):
async def app_instance(mocker, capfd, app_config, tmpdir):

async def nop():
pass

mocker.patch("main.App.user_data_dir", tmpdir)

app = PythonHereApp()
app._on_ssh_connection_made = app.on_ssh_connection_made
app.on_ssh_connection_made = mocker.Mock()
Expand Down Expand Up @@ -99,3 +101,16 @@ def preserve_cwd():

sys.path = original_path[:]
os.chdir(original_cwd)


@pytest.fixture
def mocked_android_modules(mocker):
sys.modules["jnius"] = mocker.Mock()
sys.modules["android"] = mocker.Mock()


@pytest.fixture
def test_py_script(app_instance):
path = Path(app_instance.upload_dir) / "test.py"
path.touch()
return str(path)
26 changes: 26 additions & 0 deletions tests/test_android_here.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import pytest
from pathlib import Path


@pytest.mark.parametrize(
"script", (None, "test.py"),
)
def test_restart_app(mocked_android_modules, app_instance, test_py_script, script):
from android_here import restart_app
restart_app(script)


def test_script_path_resolved(mocked_android_modules, app_instance, test_py_script):
from android_here import resolve_script_path
path = resolve_script_path("test.py")
assert path.startswith("/") and path.endswith("test.py")


def test_absolute_script_path_resolved(mocked_android_modules, app_instance, test_py_script):
from android_here import resolve_script_path
assert resolve_script_path(test_py_script) == test_py_script


def test_pin_shortcut(mocker, mocked_android_modules, app_instance, test_py_script):
from android_here import pin_shortcut
pin_shortcut("test.py", "test label")
46 changes: 46 additions & 0 deletions tests/test_launcher_here.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
from launcher_here import run_script, try_startup_script


def test_run_script(mocker, test_py_script):
run_path = mocker.patch("runpy.run_path")
run_script(test_py_script)
run_path.assert_called_once()


def test_run_script_not_found(mocker):
run_path = mocker.patch("runpy.run_path")
run_script("not_exist.py")
run_path.assert_not_called()


def test_try_startup_script_not_android(mocker):
run_script = mocker.patch("launcher_here.run_script")
try_startup_script()
run_script.assert_not_called()


def test_try_startup_script(mocker, mocked_android_modules):
mocker.patch("launcher_here.platform", "android")
run_script = mocker.patch("launcher_here.run_script")

try_startup_script()
run_script.assert_called_once()


def test_try_startup_exception(mocker, mocked_android_modules):
mocker.patch("launcher_here.platform", "android")
logger_exception = mocker.patch("launcher_here.Logger.exception")
run_script = mocker.patch("launcher_here.run_script", side_effect=Exception())

try_startup_script()
run_script.assert_called_once()
logger_exception.assert_called_once()


def test_try_startup_no_script(mocker, mocked_android_modules):
mocker.patch("launcher_here.platform", "android")
mocker.patch("android_here.get_startup_script", return_value=None)
run_script = mocker.patch("launcher_here.run_script")

try_startup_script()
run_script.assert_not_called()

0 comments on commit 8ae60e2

Please sign in to comment.