├── src ├── __init__.py ├── img │ ├── icon.png │ └── splash.png ├── config.py ├── cross.py ├── wormhole.kv ├── main.py └── magic.py ├── tests ├── __init__.py └── test_magic.py ├── metadata └── en-US │ ├── title.txt │ ├── short_description.txt │ └── full_description.txt ├── misc ├── file_paths.xml ├── intent_filters.xml └── content_providers.xml ├── .gitignore ├── .editorconfig ├── requirements.in ├── CHANGELOG.md ├── README.md ├── requirements.txt └── COPYING /src/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /metadata/en-US/title.txt: -------------------------------------------------------------------------------- 1 | Wormhole 2 | -------------------------------------------------------------------------------- /src/img/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pavelsof/mobile-wormhole/HEAD/src/img/icon.png -------------------------------------------------------------------------------- /metadata/en-US/short_description.txt: -------------------------------------------------------------------------------- 1 | Send and receive files using the Magic Wormhole protocol 2 | -------------------------------------------------------------------------------- /src/img/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pavelsof/mobile-wormhole/HEAD/src/img/splash.png -------------------------------------------------------------------------------- /misc/file_paths.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /misc/intent_filters.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # virtual env 2 | /venv/* 3 | 4 | # buildozer build dir 5 | /.buildozer/* 6 | 7 | # buildozer output dir 8 | /bin/* 9 | 10 | # config file 11 | /src/wormhole.ini 12 | 13 | # python temps 14 | *.pyc 15 | 16 | # editor temps 17 | *.swp 18 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | 9 | [*.py] 10 | indent_style = space 11 | indent_size = 4 12 | max_line_length = 79 13 | 14 | [*.kv] 15 | indent_style = space 16 | indent_size = 4 17 | -------------------------------------------------------------------------------- /misc/content_providers.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /requirements.in: -------------------------------------------------------------------------------- 1 | # what goes in the app 2 | kivy[base]==2.1.0 3 | magic-wormhole==0.12.0 4 | plyer==2.0.0 5 | 6 | # what builds the app 7 | git+https://github.com/pavelsof/buildozer@c4b4fdf0c803418a23efee9852702e67414617c5 8 | cython==0.29.32 9 | 10 | # unit tests 11 | pytest==7.1.3 12 | pytest-twisted==1.13.4 13 | 14 | # other tools 15 | ipython==8.5.0 16 | pip-tools==6.8.0 17 | pipdeptree==2.3.1 18 | pyflakes==2.5.0 19 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # changelog 2 | 3 | ## 0.2.1 (2021-01-23) 4 | 5 | - The app keeps the screen on while in foreground ([#8](https://github.com/pavelsof/mobile-wormhole/issues/8)). 6 | 7 | 8 | ## 0.2.0 (2020-12-12) 9 | 10 | - Upgraded to Kivy 2.0. 11 | - Added a config screen for setting the rendezvous and transit relay servers ([#4](https://github.com/pavelsof/mobile-wormhole/issues/4)). 12 | - The send/receive screens now show the file upload/download progress ([#5](https://github.com/pavelsof/mobile-wormhole/issues/5)). 13 | -------------------------------------------------------------------------------- /metadata/en-US/full_description.txt: -------------------------------------------------------------------------------- 1 | This is an Android client for the Magic Wormhole protocol. You can use it to exchange files between your phone and another Magic Wormhole client, including the original command-line client. 2 | 3 | How to send files 4 | 5 | * From the main screen, tap the "send" button. Then use the "choose file" button to pick the file that you want to send. 6 | * Alternatively, open the file that you want to send and tap the share icon: Wormhole should be listed among the apps you can use to share the file. 7 | * Either way, the app will have generated a code, a random number followed by two random words. The person to whom you want to send the file will have to enter this code in their Magic Wormhole client. The code is also copied to the clipboard for convenience. 8 | * Tap the "send" button. When the user at the other end of the wormhole enters the code and confirms, the file will start transferring. 9 | * The "send" button will change to "done" when the transfer is complete. 10 | 11 | How to receive files 12 | 13 | * From the main screen, tap the "receive" button. 14 | * Enter the code that the sender has provided you with and tap the "connect" button. 15 | * You will see the incoming file's name and size. If all looks good, tap the "receive" button to start the file transfer. 16 | * When the file is downloaded from the wormhole, the "receive" button will change to an "open dir" button. 17 | 18 | The app is still in beta and there is still a lot to be desired. However, the basic functionality of sending and receiving files seems to be there. I find it useful, so someone else might as well. 19 | 20 | The code is open sourced under GPLv3. Pull requests are welcome. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Wormhole 2 | 3 | This is a work-in-progress mobile client for the [magic wormhole][magic-wormhole] protocol. What already works: 4 | 5 | - Sending files. The app can obtain a code, exchange keys with the other party, allow the user to pick a file to send down the wormhole, and then actually transfer the file. 6 | - Receiving files. The user can enter a code, view a file offer to confirm or reject, and indeed receive a file. This will be saved in the downloads directory. 7 | - Handling of Android's `ACTION_SEND` intent. The user can share a file via the app, essentially pre-selecting the file in the send screen. 8 | 9 | [Get it on F-Droid](https://f-droid.org/packages/com.pavelsof.wormhole/) 12 | [Get it on Google Play](https://play.google.com/store/apps/details?id=com.pavelsof.wormhole) 15 | 16 | ## Setup 17 | 18 | In order to leverage the original [magic wormhole][magic-wormhole] package and also because I was curious whether it will even work, the code is written in Python using [Kivy][kivy] and friends. 19 | 20 | ```sh 21 | # create a virtual environment 22 | # version 3.8 should work 23 | python3 -m venv venv 24 | source venv/bin/activate 25 | 26 | # install the dependencies 27 | # pip-sync is also fine 28 | pip install --upgrade pip 29 | pip install -r requirements.txt 30 | 31 | # run the (too) few unit tests 32 | pytest tests 33 | 34 | # run locally 35 | python src/main.py 36 | 37 | # run on an android phone 38 | buildozer android debug deploy run logcat 39 | ``` 40 | 41 | If you also feel adventurous about mobile apps in Python.. pull requests are welcome :) 42 | 43 | 44 | ## Licence 45 | 46 | GPL. You can do what you want with this code as long as you let others do the same. 47 | 48 | [magic-wormhole]: https://github.com/warner/magic-wormhole 49 | [kivy]: https://github.com/kivy/kivy 50 | -------------------------------------------------------------------------------- /src/config.py: -------------------------------------------------------------------------------- 1 | from kivy.app import App 2 | from kivy.uix.screenmanager import Screen 3 | from wormhole.cli.public_relay import RENDEZVOUS_RELAY, TRANSIT_RELAY 4 | 5 | 6 | SECTION_NAME = 'wormhole' 7 | 8 | DEFAULT_VALUES = { 9 | 'rendezvous_relay': RENDEZVOUS_RELAY, 10 | 'transit_relay': TRANSIT_RELAY, 11 | } 12 | 13 | FIELD_NAMES = frozenset(DEFAULT_VALUES.keys()) 14 | 15 | 16 | class ConfigScreen(Screen): 17 | 18 | def on_pre_enter(self): 19 | """ 20 | Set the values of the text inputs. Assume that self.config has been 21 | already set by the ConfigMixin.build_settings method. 22 | 23 | Called just before the user enters this screen. 24 | """ 25 | assert self.config 26 | 27 | for field_name in FIELD_NAMES: 28 | field = getattr(self.ids, field_name) 29 | field.text = self.config.get(SECTION_NAME, field_name) 30 | 31 | def reset_field(self, field_name): 32 | """ 33 | Reset the value of the given field to its default. 34 | """ 35 | field = getattr(self.ids, field_name) 36 | field.text = DEFAULT_VALUES[field_name] 37 | 38 | def update_config(self): 39 | """ 40 | Update the config with the values that happen to be in the input fields 41 | when the user calls this action. 42 | """ 43 | for field_name in FIELD_NAMES: 44 | value = getattr(self.ids, field_name).text 45 | self.config.set(SECTION_NAME, field_name, value) 46 | 47 | self.config.write() 48 | 49 | 50 | class ConfigMixin: 51 | """ 52 | Mixin for our App subclass that determines the config options and the way 53 | to display these to the user. 54 | """ 55 | settings_cls = ConfigScreen 56 | use_kivy_settings = False 57 | 58 | def build_config(self, config): 59 | """ 60 | Mutate self.config before the config file (if this exists) is loaded in 61 | order to provide default config values. 62 | 63 | Called once, before the application is initialised. 64 | """ 65 | config.setdefaults(SECTION_NAME, DEFAULT_VALUES) 66 | 67 | def build_settings(self, settings): 68 | """ 69 | Mutate the settings widget before this is shown to the user in order to 70 | link in the config. 71 | 72 | Called once, when the settings widget is created. 73 | """ 74 | settings.config = self.config 75 | 76 | def display_settings(self, settings): 77 | """ 78 | Determine the way to show the settings widget to the user. In our case, 79 | attach the config screen (if it is not already) and switch to it. 80 | 81 | Called by the app.open_settings method. 82 | """ 83 | if not self.screen_manager.has_screen('config_screen'): 84 | self.screen_manager.add_widget(settings) 85 | 86 | self.screen_manager.current = 'config_screen' 87 | 88 | 89 | def get_config(): 90 | """ 91 | Return a dict mapping all config keys to their stored values. 92 | """ 93 | app = App.get_running_app() 94 | return { 95 | field_name: app.config.get(SECTION_NAME, field_name) 96 | for field_name in FIELD_NAMES 97 | } 98 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with python 3.10 3 | # To update, run: 4 | # 5 | # pip-compile 6 | # 7 | asttokens==2.0.8 8 | # via stack-data 9 | attrs==22.1.0 10 | # via 11 | # autobahn 12 | # automat 13 | # magic-wormhole 14 | # pytest 15 | # service-identity 16 | # twisted 17 | autobahn[twisted]==22.7.1 18 | # via magic-wormhole 19 | automat==20.2.0 20 | # via 21 | # magic-wormhole 22 | # twisted 23 | # txtorcon 24 | backcall==0.2.0 25 | # via ipython 26 | build==0.8.0 27 | # via pip-tools 28 | buildozer @ git+https://github.com/pavelsof/buildozer@c4b4fdf0c803418a23efee9852702e67414617c5 29 | # via -r requirements.in 30 | certifi==2022.6.15.2 31 | # via requests 32 | cffi==1.15.1 33 | # via 34 | # cryptography 35 | # pynacl 36 | charset-normalizer==2.1.1 37 | # via requests 38 | click==8.1.3 39 | # via 40 | # magic-wormhole 41 | # pip-tools 42 | constantly==15.1.0 43 | # via twisted 44 | cryptography==38.0.1 45 | # via 46 | # autobahn 47 | # pyopenssl 48 | # service-identity 49 | # txtorcon 50 | cython==0.29.32 51 | # via -r requirements.in 52 | decorator==5.1.1 53 | # via 54 | # ipython 55 | # pytest-twisted 56 | distlib==0.3.6 57 | # via virtualenv 58 | docutils==0.19 59 | # via kivy 60 | executing==1.0.0 61 | # via stack-data 62 | filelock==3.8.0 63 | # via virtualenv 64 | greenlet==1.1.3 65 | # via pytest-twisted 66 | hkdf==0.0.3 67 | # via 68 | # magic-wormhole 69 | # spake2 70 | humanize==4.3.0 71 | # via magic-wormhole 72 | hyperlink==21.0.0 73 | # via 74 | # autobahn 75 | # twisted 76 | idna==3.3 77 | # via 78 | # hyperlink 79 | # requests 80 | # twisted 81 | incremental==21.3.0 82 | # via 83 | # twisted 84 | # txtorcon 85 | iniconfig==1.1.1 86 | # via pytest 87 | ipython==8.5.0 88 | # via -r requirements.in 89 | jedi==0.18.1 90 | # via ipython 91 | kivy[base]==2.1.0 92 | # via -r requirements.in 93 | kivy-garden==0.1.5 94 | # via kivy 95 | magic-wormhole==0.12.0 96 | # via -r requirements.in 97 | matplotlib-inline==0.1.6 98 | # via ipython 99 | packaging==21.3 100 | # via 101 | # build 102 | # pytest 103 | parso==0.8.3 104 | # via jedi 105 | pep517==0.13.0 106 | # via build 107 | pexpect==4.8.0 108 | # via 109 | # buildozer 110 | # ipython 111 | pickleshare==0.7.5 112 | # via ipython 113 | pillow==9.2.0 114 | # via kivy 115 | pip-tools==6.8.0 116 | # via -r requirements.in 117 | pipdeptree==2.3.1 118 | # via -r requirements.in 119 | platformdirs==2.5.2 120 | # via virtualenv 121 | pluggy==1.0.0 122 | # via pytest 123 | plyer==2.0.0 124 | # via -r requirements.in 125 | prompt-toolkit==3.0.31 126 | # via ipython 127 | ptyprocess==0.7.0 128 | # via pexpect 129 | pure-eval==0.2.2 130 | # via stack-data 131 | py==1.11.0 132 | # via pytest 133 | pyasn1==0.4.8 134 | # via 135 | # pyasn1-modules 136 | # service-identity 137 | pyasn1-modules==0.2.8 138 | # via service-identity 139 | pycparser==2.21 140 | # via cffi 141 | pyflakes==2.5.0 142 | # via -r requirements.in 143 | pygments==2.13.0 144 | # via 145 | # ipython 146 | # kivy 147 | pynacl==1.5.0 148 | # via magic-wormhole 149 | pyopenssl==22.0.0 150 | # via twisted 151 | pyparsing==3.0.9 152 | # via packaging 153 | pytest==7.1.3 154 | # via 155 | # -r requirements.in 156 | # pytest-twisted 157 | pytest-twisted==1.13.4 158 | # via -r requirements.in 159 | requests==2.28.1 160 | # via kivy-garden 161 | service-identity==21.1.0 162 | # via twisted 163 | sh==1.14.3 164 | # via buildozer 165 | six==1.16.0 166 | # via 167 | # asttokens 168 | # automat 169 | # magic-wormhole 170 | # service-identity 171 | spake2==0.8 172 | # via magic-wormhole 173 | stack-data==0.5.0 174 | # via ipython 175 | tomli==2.0.1 176 | # via 177 | # build 178 | # pep517 179 | # pytest 180 | tqdm==4.64.1 181 | # via magic-wormhole 182 | traitlets==5.4.0 183 | # via 184 | # ipython 185 | # matplotlib-inline 186 | twisted[tls]==22.8.0 187 | # via 188 | # autobahn 189 | # magic-wormhole 190 | # txtorcon 191 | txaio==22.2.1 192 | # via autobahn 193 | txtorcon==22.0.0 194 | # via magic-wormhole 195 | typing-extensions==4.3.0 196 | # via twisted 197 | urllib3==1.26.12 198 | # via requests 199 | virtualenv==20.16.5 200 | # via buildozer 201 | wcwidth==0.2.5 202 | # via prompt-toolkit 203 | wheel==0.37.1 204 | # via pip-tools 205 | zope-interface==5.4.0 206 | # via 207 | # autobahn 208 | # twisted 209 | # txtorcon 210 | 211 | # The following packages are considered to be unsafe in a requirements file: 212 | # pip 213 | # setuptools 214 | -------------------------------------------------------------------------------- /tests/test_magic.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | from unittest.mock import Mock 3 | 4 | import pytest 5 | import pytest_twisted 6 | from twisted.internet import reactor 7 | from twisted.internet.defer import Deferred, fail, succeed 8 | from wormhole.errors import WrongPasswordError 9 | from wormhole.util import dict_to_bytes 10 | 11 | from src.magic import HumanError, SuspiciousOperation, Timeout, Wormhole 12 | 13 | 14 | @pytest.fixture 15 | def upstream(monkeypatch): 16 | upstream = Mock() 17 | 18 | def mock_init(self): 19 | self.app_id = '' 20 | self.transit_relay = '' 21 | self.transit = None 22 | self.wormhole = upstream 23 | 24 | monkeypatch.setattr(Wormhole, '__init__', mock_init) 25 | 26 | return upstream 27 | 28 | 29 | @pytest.fixture 30 | def file_path(tmpdir): 31 | file_path = os.path.join(tmpdir, 'file') 32 | 33 | with open(file_path, 'w') as f: 34 | f.write('hi!') 35 | 36 | return file_path 37 | 38 | 39 | @pytest_twisted.inlineCallbacks 40 | def test_generate_code_timeout(upstream): 41 | """ 42 | The Wormhole.generate_code method should be able to handle timeouts. 43 | """ 44 | def get_code(): 45 | deferred = Deferred() 46 | reactor.callLater(2, deferred.callback, 'code') 47 | return deferred 48 | upstream.get_code = get_code 49 | 50 | wormhole = Wormhole() 51 | with pytest.raises(Timeout): 52 | yield wormhole.generate_code(timeout=0) 53 | 54 | 55 | @pytest_twisted.inlineCallbacks 56 | def test_generate_code_works(upstream): 57 | """ 58 | The Wormhole.generate_code method should resolve into a code generated by 59 | the upstream if all goes well. 60 | """ 61 | upstream.get_code = lambda: succeed('code') 62 | 63 | wormhole = Wormhole() 64 | res = yield wormhole.generate_code() 65 | assert res == 'code' 66 | 67 | 68 | @pytest_twisted.inlineCallbacks 69 | def test_connect_works(upstream): 70 | """ 71 | The Wormhole.connect method should resolve if the upstream resolves. 72 | """ 73 | upstream.get_code = lambda: succeed(None) 74 | wormhole = Wormhole() 75 | res = yield wormhole.connect('code') 76 | assert res is None 77 | 78 | 79 | @pytest_twisted.inlineCallbacks 80 | def test_exchange_keys_bad_code(upstream): 81 | """ 82 | The Wormhole.exchange_keys method should be able to handle the upstream's 83 | WrongPasswordError. 84 | """ 85 | upstream.get_verifier = lambda: fail(WrongPasswordError()) 86 | 87 | wormhole = Wormhole() 88 | with pytest.raises(HumanError): 89 | yield wormhole.exchange_keys() 90 | 91 | 92 | @pytest_twisted.inlineCallbacks 93 | def test_exchange_keys_works(upstream): 94 | """ 95 | The Wormhole.exchange_keys method should resolve into a verifier coming 96 | from the upstream if all goes well. 97 | """ 98 | upstream.get_verifier = lambda: succeed('verifier') 99 | 100 | wormhole = Wormhole() 101 | res = yield wormhole.exchange_keys() 102 | assert res == 'verifier' 103 | 104 | 105 | @pytest_twisted.inlineCallbacks 106 | def test_await_json_timeout(upstream): 107 | """ 108 | The Wormhole.await_json method should be able to handle timeouts. 109 | """ 110 | def get_message(): 111 | deferred = Deferred() 112 | reactor.callLater(2, deferred.callback, dict_to_bytes({})) 113 | return deferred 114 | upstream.get_message = get_message 115 | 116 | wormhole = Wormhole() 117 | with pytest.raises(Timeout): 118 | yield wormhole.await_json(timeout=0) 119 | 120 | 121 | @pytest_twisted.inlineCallbacks 122 | def test_await_json_bad_message(upstream): 123 | """ 124 | The Wormhole.await_json method should not break if the upstream resolves 125 | into a message that does not conform to the wormhole spec. 126 | """ 127 | upstream.get_message = lambda: succeed('some string') 128 | 129 | wormhole = Wormhole() 130 | with pytest.raises(SuspiciousOperation): 131 | yield wormhole.await_json() 132 | 133 | 134 | @pytest_twisted.inlineCallbacks 135 | def test_await_json_works(upstream): 136 | """ 137 | The Wormhole.await_json method should resolve into a dict parsed from the 138 | upstream's return value. 139 | """ 140 | upstream.get_message = lambda: succeed(dict_to_bytes({'answer': 42})) 141 | 142 | wormhole = Wormhole() 143 | res = yield wormhole.await_json() 144 | assert res == {'answer': 42} 145 | 146 | 147 | @pytest_twisted.inlineCallbacks 148 | def test_send_file_error(upstream, file_path): 149 | """ 150 | The Wormhole.send_file method should close the wormhole and reject with an 151 | appropriate error if the other side sends an error message. 152 | """ 153 | upstream.get_message = lambda: succeed(dict_to_bytes({'error': '!'})) 154 | 155 | wormhole = Wormhole() 156 | 157 | with pytest.raises(SuspiciousOperation) as exc_info: 158 | yield wormhole.send_file(file_path, lambda _: None) 159 | 160 | assert str(exc_info.value) == '!' 161 | assert upstream.close.called 162 | 163 | 164 | @pytest_twisted.inlineCallbacks 165 | def test_await_offer_error(upstream): 166 | """ 167 | The Wormhole.await_offer method should close the wormhole and reject with 168 | an appropriate error if the other side sends an error message. 169 | """ 170 | upstream.get_message = lambda: succeed(dict_to_bytes({'error': '!'})) 171 | upstream.derive_key = lambda *args: bytes('key', 'utf-8') 172 | 173 | wormhole = Wormhole() 174 | 175 | with pytest.raises(SuspiciousOperation) as exc_info: 176 | yield wormhole.await_offer() 177 | 178 | assert str(exc_info.value) == '!' 179 | assert upstream.close.called 180 | -------------------------------------------------------------------------------- /src/cross.py: -------------------------------------------------------------------------------- 1 | import mimetypes 2 | import os 3 | 4 | from kivy.utils import platform as PLATFORM 5 | 6 | 7 | if PLATFORM == 'android': 8 | import android.activity 9 | from android import mActivity 10 | from android.permissions import Permission 11 | from android.permissions import check_permission, request_permissions 12 | from android.storage import primary_external_storage_path 13 | from jnius import autoclass 14 | from plyer.platforms.android.filechooser import AndroidFileChooser 15 | 16 | File = autoclass('java.io.File') 17 | 18 | Intent = autoclass('android.content.Intent') 19 | Environment = autoclass('android.os.Environment') 20 | FileProvider = autoclass('android.support.v4.content.FileProvider') 21 | 22 | class AndroidUriResolver(AndroidFileChooser): 23 | """ 24 | Leverage Plyer's file chooser for resolving Android URIs. 25 | """ 26 | 27 | def __init__(self): 28 | pass 29 | 30 | def resolve(self, uri): 31 | return self._resolve_uri(uri) 32 | 33 | 34 | """ 35 | permissions 36 | """ 37 | 38 | 39 | def ensure_storage_perms(fallback_func): 40 | """ 41 | Decorator that ensures that the decorated function is only run if the user 42 | has granted the app permissions to write to the file system. Otherwise the 43 | fallback function is called instead. 44 | 45 | Because permissions on Android are requested asynchronously, the decorated 46 | function should not be expected to return a value. 47 | """ 48 | def outer_wrapper(func): 49 | def inner_wrapper(*args, **kwargs): 50 | if PLATFORM == 'android': 51 | if check_permission(Permission.WRITE_EXTERNAL_STORAGE): 52 | return func(*args, **kwargs) 53 | 54 | def callback(permissions, grant_results): 55 | if grant_results[0]: 56 | return func(*args, **kwargs) 57 | else: 58 | return fallback_func() 59 | 60 | request_permissions( 61 | [Permission.WRITE_EXTERNAL_STORAGE], callback 62 | ) 63 | return 64 | 65 | return func(*args, **kwargs) 66 | 67 | return inner_wrapper 68 | return outer_wrapper 69 | 70 | 71 | """ 72 | directories 73 | """ 74 | 75 | 76 | def get_downloads_dir(): 77 | """ 78 | Return the path to the user's downloads dir. 79 | """ 80 | if PLATFORM == 'android': 81 | return os.path.join( 82 | primary_external_storage_path(), 83 | Environment.DIRECTORY_DOWNLOADS 84 | ) 85 | else: 86 | return os.getcwd() 87 | 88 | 89 | def open_file(path): 90 | """ 91 | Open the specified file. 92 | 93 | On Android, for the ACTION_VIEW to work, the Uri supplied to the Intent has 94 | to be sanctioned by the FileProvider (i.e. Uri.fromFile does not work), so: 95 | 96 | - The legacy android.support.v4.content.FileProvider has to be added to the 97 | app because AndroidX is not yet supported by Kivy [1]. In practice, this 98 | means adding a gradle dependency in buildozer.spec. 99 | - The FileProvider requires a bit of boilerplate XML in the AndroidManifest 100 | and one other file [2]. 101 | 102 | As the second does not seem to be currently configurable via Buildozer, the 103 | latter is set to use a dedicated fork and branch of python-for-android [3]. 104 | 105 | [1]: https://github.com/kivy/python-for-android/issues/2020 106 | [2]: https://developer.android.com/reference/android/support/v4/content/FileProvider 107 | [3]: https://github.com/pavelsof/python-for-android/tree/with-fileprovider 108 | """ 109 | mime_type, _ = mimetypes.guess_type(path) 110 | 111 | if PLATFORM == 'android': 112 | uri = FileProvider.getUriForFile( 113 | mActivity, 'com.pavelsof.wormhole.fileprovider', File(path) 114 | ) 115 | 116 | intent = Intent() 117 | intent.setAction(Intent.ACTION_VIEW) 118 | intent.setDataAndType(uri, mime_type) 119 | intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) 120 | intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) 121 | 122 | mActivity.startActivity(intent) 123 | 124 | 125 | """ 126 | handling intents 127 | """ 128 | 129 | 130 | class IntentHandler: 131 | 132 | def __init__(self): 133 | """ 134 | On Android, set the handler listening for incoming ACTION_SEND intents, 135 | including the intent that has launched the current activity. 136 | 137 | On other platforms, do nothing. 138 | """ 139 | self.data = None 140 | self.error = None 141 | 142 | if PLATFORM == 'android': 143 | self.uri_resolver = AndroidUriResolver() 144 | self.handle_android_intent(mActivity.getIntent()) 145 | android.activity.bind(on_new_intent=self.handle_android_intent) 146 | 147 | def handle_android_intent(self, intent): 148 | """ 149 | Handle incoming ACTION_SEND intents on Android. 150 | """ 151 | if intent.getAction() != 'android.intent.action.SEND': 152 | return 153 | 154 | self.data = None 155 | self.error = None 156 | 157 | try: 158 | if intent.getData(): 159 | uri = intent.getData() 160 | 161 | else: 162 | clipData = intent.getClipData() 163 | 164 | assert clipData is not None 165 | assert clipData.getItemCount() 166 | 167 | uri = clipData.getItemAt(0).getUri() 168 | 169 | self.data = self.uri_resolver.resolve(uri) 170 | except (AttributeError, AssertionError): 171 | self.error = ( 172 | 'Your share target cannot be recognised as a file. ' 173 | 'If it is indeed one, ' 174 | 'please try selecting it via the file chooser instead.' 175 | ) 176 | 177 | def pop(self): 178 | """ 179 | If there is a file path in our improvised single-slot buffer, pop it. 180 | If there is an error instead, raise it. Otherwise return None. 181 | """ 182 | if self.error: 183 | error = str(self.error) 184 | self.error = None 185 | raise ValueError(error) 186 | else: 187 | data = self.data 188 | self.data = None 189 | return data 190 | 191 | 192 | intent_hander = IntentHandler() 193 | -------------------------------------------------------------------------------- /src/wormhole.kv: -------------------------------------------------------------------------------- 1 | #:kivy 1.11.1 2 | 3 |