├── 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 | [
](https://f-droid.org/packages/com.pavelsof.wormhole/)
12 | [
](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 |