kivyLauncher で Google Maps ランチャくらいならすぐ書ける

この見出しをポジティブに取る?

kivyLauncher で Google Maps ランチャくらいならすぐ書ける

鍋の具

公式なドキュメント:

一つだけ公式サイトの例から引用:

javaだぞ
1 // Creates an Intent that will load a map of San Francisco
2 Uri gmmIntentUri = Uri.parse("geo:37.7749,-122.4194");
3 Intent mapIntent = new Intent(Intent.ACTION_VIEW, gmmIntentUri);
4 mapIntent.setPackage("com.google.android.apps.maps");
5 startActivity(mapIntent);

なので jnius を使った kivy アプリケーションとしては、核心部分としてはたとえばこんな具合:

 1 from jnius import autoclass, cast
 2 # ...
 3 Intent = autoclass('android.content.Intent')
 4 PythonActivity = autoclass('org.renpy.android.PythonActivity')
 5 Uri = autoclass('android.net.Uri')
 6 # ...
 7 class Hoge(...):
 8     # ...
 9     def hoge(...):
10         #
11         uri = "geo:"
12         uri += "{lat},{lon}".format(
13             lon=-122.4194, lat=37.7749
14             )
15         #
16         intent = Intent(
17             Intent.ACTION_VIEW,
18             Uri.parse(uri))
19         intent.setPackage("com.google.android.apps.maps")
20 
21         PythonActivity.mActivity.startActivity(intent)

この部分だけに関しての注意点としては、ドキュメントが「経度、緯度を指定します」と説明している箇所が実際は「緯度, 経度」な点くらい。特に難しいことはない。

例えば出来てみた

android.txt
1 title=GoogleMaps Launcher Demo 1
2 author=hhsprings
3 orientation=all
main.py
  1 # -*- coding: utf-8 -*-
  2 from kivy.lang import Builder
  3 from kivy.app import App
  4 from jnius import autoclass, cast
  5 from android import activity
  6 from kivy.logger import Logger
  7 from kivy.uix.boxlayout import BoxLayout
  8 from kivy.properties import StringProperty
  9 from kivy.clock import mainthread
 10 import urllib
 11 
 12 # -----------------------
 13 from plyer import gps
 14 
 15 class _GPS(object):
 16     def __init__(self, **kwargs):
 17         self._ready = False
 18         self.lon = None
 19         self.lat = None
 20         self.alt = None
 21         gps.configure(
 22             on_status=self._on_status,
 23             on_location=self._on_location)
 24         gps.start()
 25 
 26     @property
 27     def ready(self):
 28         return self._ready
 29 
 30     @mainthread
 31     def _on_status(self, stype, status):
 32         pass
 33 
 34     @mainthread
 35     def _on_location(self, **kwargs):
 36         self.lon = float(kwargs['lon'])
 37         self.lat = float(kwargs['lat'])
 38         self.alt = float(kwargs['altitude'])
 39         self._ready = True
 40 
 41 _gps = _GPS()
 42 # -----------------------
 43 
 44 kv = '''
 45 <MyRootWidget>:
 46     orientation: 'vertical'
 47     Button:
 48         text: 'geo 1'
 49         on_release: app.do_geo_1()
 50     BoxLayout:
 51         orientation: 'vertical'
 52         TextInput:
 53             multiline: False
 54             font_name: "NotoSansJP-Regular.otf"
 55             on_text:
 56                 app.navigation_query = args[1].strip()
 57         Button:
 58             text: 'navigation 1'
 59             on_release: app.do_navigation_1()
 60     Button:
 61         text: 'streetview 1'
 62         on_release: app.do_streetview_1()
 63 '''
 64 Builder.load_string(kv)
 65 
 66 #
 67 Intent = autoclass('android.content.Intent')
 68 PythonActivity = autoclass('org.renpy.android.PythonActivity')
 69 Uri = autoclass('android.net.Uri')
 70 
 71 class MyRootWidget(BoxLayout):
 72     pass
 73 
 74 class GoogleMapsDemo0App(App):
 75 
 76     navigation_query = StringProperty()
 77 
 78     def build(self):
 79         #
 80         return MyRootWidget()
 81 
 82     def do_geo_1(self, *args, **kwargs):
 83         if not _gps.ready:
 84             return
 85 
 86         #
 87         uri = "geo:"
 88         uri += "{lat},{lon}".format(
 89             lon=_gps.lon, lat=_gps.lat
 90             )
 91         #
 92         intent = Intent(
 93             Intent.ACTION_VIEW,
 94             Uri.parse(uri))
 95         intent.setPackage("com.google.android.apps.maps")
 96 
 97         PythonActivity.mActivity.startActivity(intent)
 98 
 99     def do_navigation_1(self, *args, **kwargs):
100         if not _gps.ready:
101             return
102 
103         #
104         uri = "google.navigation:"
105         uri += urllib.urlencode({
106                 "q": self.navigation_query.encode("utf-8"),
107                 # [mode] d: drive? (car) / w: walk / b: bicycle
108                 "mode": "w",
109                 })
110         #
111         intent = Intent(
112             Intent.ACTION_VIEW,
113             Uri.parse(uri))
114         intent.setPackage("com.google.android.apps.maps")
115 
116         PythonActivity.mActivity.startActivity(intent)
117 
118     def do_streetview_1(self, *args, **kwargs):
119         if not _gps.ready:
120             return
121 
122         #
123         bearing = 30
124         tilt = 15
125         uri = "google.streetview:"
126         uri += "cbll={lat},{lon}&cbp=0,{bearing},0,{zoom},{tilt}".format(
127             lon=_gps.lon, lat=_gps.lat, bearing=bearing,
128             zoom=0, tilt=tilt
129             )
130         #
131         intent = Intent(
132             Intent.ACTION_VIEW,
133             Uri.parse(uri))
134         intent.setPackage("com.google.android.apps.maps")
135 
136         PythonActivity.mActivity.startActivity(intent)
137 
138 if __name__ == '__main__':
139     GoogleMapsDemo0App().run()

GUI としてのレイアウトは滅茶苦茶だが、一番上のボタンが現在位置での「普通の」Google Maps 呼び出し、二つ目のテキストボックスとボタンのセットは「ナビゲーション」、三つ目のボタンが現在位置のストリートビュー。

これだけだと素で(正規の)Google Maps アプリ使うのより何か嬉しいなんてことはないが、何か出来ないこともないだろう。

さーて、色々出来そうだぞぉ?

と思ってもそうは問屋が卸さない。「kivyLauncher で Google Maps ランチャくらいならすぐ書ける」の実態は、「kivyLauncher で Google Maps ランチャくらいしか書けない」の意味なのであった。

kivyLauncher + zxing でバーコードスキャンでは、バーコードスキャナから戻ってきて結果を受け取ることが出来たのである。このコミュニケーションのカギは startActivityForResult である:

 1 # ...
 2         activity.bind(on_activity_result=self.on_activity_result)
 3 
 4         #
 5         intent = Intent("com.google.zxing.client.android.SCAN")
 6         # SCAN_MODE: PRODUCT_MODE or QR_CODE_MODE
 7         intent.putExtra("SCAN_MODE", "PRODUCT_MODE")
 8         PythonActivity.mActivity.startActivityForResult(intent, 0)
 9 
10 # ...
11     def on_activity_result(self, requestCode, resultCode, intent):
12         Logger.info("resultCode={}".format(resultCode))
13         if requestCode != 0:
14             return
15         contents = intent.getStringExtra("SCAN_RESULT")
16         format = intent.getStringExtra("SCAN_RESULT_FORMAT")

今回「出来てみた」Intent.ACTION_VIEW の例でも startActivityForResult でアクティビティを開始することは出来る。けれども、決して on_activity_result に戻ってくることはない。

kivyLauncher の場合さらに悪いことに、Intent.ACTION_VIEW でアクティビティが Google Maps に移ると、Google Maps から戻るボタンで戻ってきても、「kivyLauncher」にしか戻ってこない。簡単に言えば「ワタシのアプリケーションは終了しちまっている」。

ニーズの半分は云々で、半分はかんぬんで

プログラマティックに Google Maps を呼び出すというニーズの半分は、今回例で書いた「ランチャ」相当のものでも十分なものかもしれない。つまり、「なにがしかの演算結果の最終結果の視覚化」であれば、まぁこんなんでもいいんだろうと思う。

けれど、もう半分のニーズはやはり自アプリケーションに戻ってこなければどうしようもないものだろう。Google Maps から何か結果を得たいわけではなくてもいいのだ。けれども制御は戻ってこないと困る。

他のアプローチはないの?

埋め込みブラウザのアプローチが取れて、url 指定だけでブラウザ起動、で誤魔化せる範囲内ならばどうだろうか?

これは webkit でおそらく出来るはずなのだが、 Embedding a webview into another view にあるように、java で書いても一ひねり加えなければならないらしい:

試せていないのでほんとにこれが期待のものなのかは不明。おそらくそうだ、と勝手に想像している。
 1 // override default behaviour of the browser
 2 private class MyWebViewClient extends WebViewClient {
 3         @Override
 4         public boolean shouldOverrideUrlLoading(WebView view, String url) {
 5             view.loadUrl(url);
 6             return true;
 7         }
 8     }
 9 
10 public void goToWebView(View view) {
11         setContentView(R.layout.web_view);
12         WebView mWebView = (WebView) findViewById(R.id.webview);
13         // add the following line ----------
14         mWebView.setWebViewClient(new MyWebViewClient());
15         mWebView.getSettings().setJavaScriptEnabled(true);
16         mWebView.loadUrl("http://www.google.com");
17     }

WebViewClient の API ドキュメントをみるとわかるが shouldOverrideUrlLoading メソッドはオーバライドされることが望まれていない。少なくとも「java のインターフェイスとしては」。interface メソッドでもないし、@abstract とかでもない。が、説明のみでオーバライドされることが想定されている。つまりお行儀良くない。

WebView、WebViewClient 自体にはアクセス出来るので、「埋め込み」でなく「呼んだらハイそれまでよ」で良ければ簡単…、なのは、今回の Intent.ACTION_VIEW で起こったことと全く同じ。

Embedding a webview into another view のコードを jnius を駆使して書けないだろうか、と少し探っていたのだが、WebViewClient の java 「インターフェイス」(interface とは限らない)のお行儀悪さのせいで、どうにもならなかった。簡単に言えば:

1 from jnius import PythonJavaClass, java_method, autoclass, MetaJavaClass
2 WebView = autoclass('android.webkit.WebView')
3 WebViewClient = autoclass('android.webkit.WebViewClient')
4 activity = autoclass('org.renpy.android.PythonActivity').mActivity
5 
6 class MyWebViewClient(WebViewClient):  # これが既に NG

あれこれやってるうちにようやくわかってきたレベルで、まだあまりうまく説明出来ないのだが、「本物の java である WebViewClient」を普通の Python class として継承は出来ない、ということ(上の例の場合は、MyWebViewClient も java の実体がいなければならない)。対して、「java interface を Python クラスとして書く」ことは出来て:

PythonJavaClass はこのためにある
1 class MagneticFieldSensorListener(PythonJavaClass):
2     __javainterfaces__ = ['android/hardware/SensorEventListener']
3 
4 # ...
5 
6     @java_method('(Landroid/hardware/SensorEvent;)V')
7     def onSensorChanged(self, event):
8         self.values = event.values  #[:3]

WebViewClient の基底クラスは java.lang.Object のみで interface もないのでどうしようもないが、interface 以外の「java 側からみても java にしか見えない python で実現された class」を書くことは、今のところ出来ないようだ(少なくともワタシは見つけられてない)。

結局のところ kivyLauncher ではこれが限界らしい

kivyLauncher に戻ってしまうのは、あくまでも android アプリケーションとしてのアクティビティ制御を握っているのは kivyLauncher であって「ワタシが書いたアプリケーションではない」から。なので kivyLauncher を離れて独立した(APK)アプリケーションを作らなければならない…、んだと思う。

kivyLauncher アプリケーションに自作の「本物の java クラス」を自由に混ぜ込むことも出来ないのだから、なので kivyLauncher を離れて独立した(APK)アプリケーションを作らなければならない…、んだと思う。

kivy/python-for-android を使うことで「Python で開発」は維持出来る(「が書いてその場で動かせる」ことはなくなる)。kivy/python-for-android で自作 java とどうやってチャンポンにするのかわからないけど、たぶん出来るんじゃないかと想像している。今回の件に限らず、kivyLauncher での制約がチラホラいやらしいので、もうちょっと大きなことがしたくなってきてから kivy/python-for-android してみようと思う。

09:10 追記、8/4更新:「kivyLauncher に戻ってしまう」の措置はないともいえない

examples/demo/kivycatalog の main.py
 1 class KivyCatalogApp(App):
 2     '''The kivy App that runs the main root. All we do is build a catalog
 3     widget into the root.'''
 4 
 5     def build(self):
 6         return Catalog()
 7 
 8     def on_pause(self):
 9         return True
10 
11 
12 if __name__ == "__main__":
13     KivyCatalogApp().run()

on_pause, on_resume の振る舞いを制御出来る。これ、つまり上記例のように on_pause() で True を返すだけで「うまくいくときはうまくいく」。

けれどこれに期待しない方がいい。やってみればすぐにハングアップ状態を経験するはず。この機能の説明では、「on_pause で大事なデータをディスク等に退避して、on_resume で復元せよ」という説明ならどこにいっても書かれているが、ハングアップすることについては「ハングアップします!」という悲鳴だけは見つかるものの、対処はどこにいっても書かれてない。

Pause modeでの説明:

There is an issue with OpenGL on Android devices: it is not guaranteed that the OpenGL ES Context will be restored when your app resumes. The mechanism for restoring all the OpenGL data is not yet implemented in Kivy.

だって。このことで何が起こるのかは書かれてはいないけれど、ともあれ、我々の制御の範疇にない OpenGL データの復元が出来ない、と言っているので、それがハングアップという形で我々の目に見える形で現れても不思議ではないということなのだろう。