diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000000000..0e61d7636b8cc --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,73 @@ +name: Build Android APK + +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] + workflow_dispatch: # Allows you to manually trigger the build from the GitHub UI + +jobs: + build: + runs-on: ubuntu-24.04 + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + # Caching Buildozer's global dir (SDK/NDK) drastically reduces build times. + # IMPORTANT: + # * do NOT cache the project-local .buildozer build tree — a build tree + # saved from a failed run poisons later runs (e.g. hostpython3 with a + # missing 'math' module, because its should_build() skips rebuilding) + # * no restore-keys — restoring older artifacts mixes toolchain versions + - name: Cache Buildozer Android SDK/NDK + uses: actions/cache@v4 + with: + path: ~/.buildozer + key: ${{ runner.os }}-buildozer-v4-${{ hashFiles('buildozer.spec') }} + + - name: Install System Dependencies + run: | + sudo apt update + sudo apt install -y \ + autoconf build-essential ccache cmake git libffi-dev \ + libncurses-dev libssl-dev libtinfo6 libtool pkg-config \ + unzip zlib1g-dev zip + + # The pinned python-for-android's libffi recipe runs 'autogen.sh' + # (autoreconf) and needs the LT_SYS_SYMBOL_USCORE macro. Without + # 'libltdl-dev' (which puts libtool's m4 on aclocal's search path) + # autoreconf fails with "possibly undefined macro: LT_SYS_SYMBOL_USCORE". + - name: Install libltdl-dev + run: | + sudo apt-get update + sudo apt-get install -y libltdl-dev + + - name: Install Python Dependencies & Buildozer + run: | + python -m pip install --upgrade pip setuptools wheel + python -m pip install --upgrade buildozer Cython Kivy + + - name: Build Android Debug APK + run: | + # The 'yes |' automatically accepts the Android SDK license prompts + yes | buildozer android debug + + - name: Upload APK Artifact + uses: actions/upload-artifact@v4 + with: + name: Kivy-Android-Debug-APK + path: bin/*.apk + if-no-files-found: error diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000..55284780c1b9a --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Python caches +__pycache__/ +*.pyc + +# Buildozer environment and cache directories +.buildozer/ +bin/ +.venv/ +ENV/ + +# OS specific hidden files +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000000000..80d98024d4f39 --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# My Kivy App + +A simple Android app built with [Kivy](https://kivy.org) and +[KivyMD](https://kivymd.readthedocs.io) — a Material Design welcome screen with +buttons to test internet access, file storage, and the ffpyplayer/ffmpeg +libraries. + +## Permissions (in `buildozer.spec`) + +- `INTERNET` — connectivity test +- `READ_EXTERNAL_STORAGE` / `WRITE_EXTERNAL_STORAGE` +- `MANAGE_EXTERNAL_STORAGE` — **All files access** storage manager + (grant via the in-app "Manage Storage" button, opened from system settings) + +## Build the APK + +The APK is built automatically by GitHub Actions on every push to `main` +(see [`.github/workflows/build.yml`](.github/workflows/build.yml)). + +To download the latest APK: + +1. Open the repo's **Actions** tab and click the latest **Build Android APK** run. +2. Download the **Kivy-Android-Debug-APK** artifact and install it on your phone. + +## Run locally (desktop) + +```bash +pip install kivy +python main.py +``` + +## Build locally (Android) + +```bash +pip install buildozer +buildozer android debug +``` + +The generated APK is written to `bin/`. diff --git a/buildozer.spec b/buildozer.spec new file mode 100644 index 0000000000000..6538e13c29bcd --- /dev/null +++ b/buildozer.spec @@ -0,0 +1,70 @@ +[app] +# (str) Title of your application +title = My Kivy App + +# (str) Package name +package.name = mykivyapp + +# (str) Package domain (needed for android packaging) +package.domain = org.example + +# (str) Source code where the main.py lives +source.dir = . + +# (list) Source files to include (let empty to include all the files) +source.include_exts = py,png,jpg,kv,atlas + +# (str) Application versioning +version = 0.2 + +# (list) Application requirements +# comma separated e.g. requirements = sqlite3,kivy +# - kivymd : Material Design widgets (KivyMD) +# - ffpyplayer : pulls in the ffmpeg + SDL2 recipes automatically +# - SDL2 / OpenGL ES 2 (and 3 on supported devices) come from the 'sdl2' bootstrap below +# - pyjnius (Java <-> Python bridge) is added automatically by the android bootstrap +requirements = python3,kivy,kivymd,ffpyplayer + +# (str) Pin python-for-android to a commit that ships a known-good +# build stack: Python 3.11.5 + ffmpeg 6.1.2 + ffpyplayer 4.5.1. +# - Newer p4a defaults Python to 3.14.0, whose host build is broken +# ("No module named 'math'"), and +# - ffmpeg 8.x (p4a commit e564e44d, Jan 2026) removed +# 'libavcodec/avfft.h', breaking ffpyplayer (p4a issue #3344). +p4a.commit = c02cf781a0d3d232eb4ffbeaf580cc82cf2bd65f + +# (list) Supported orientations +orientation = portrait + +# ----------------------------------------------------------------------------- +# Android specific configuration +# ----------------------------------------------------------------------------- + +# (int) Target Android API, should be as high as possible. +android.api = 34 + +# (int) Minimum API your APK will support. +android.minapi = 21 + +# (str) Android NDK version. r25b (clang 14) is required for the pinned +# ffmpeg 6.1.2: newer NDKs use clang CFLAGS that turn the Vulkan callback +# warning in libavutil/hwcontext_vulkan.c into a hard error. +android.ndk = 25b + +# (list) App permissions +# - INTERNET and READ/WRITE_EXTERNAL_STORAGE +# - MANAGE_EXTERNAL_STORAGE = "All files access" storage manager (granted via +# system settings; main.py opens that screen through pyjnius) +android.permissions = INTERNET,READ_EXTERNAL_STORAGE,WRITE_EXTERNAL_STORAGE,MANAGE_EXTERNAL_STORAGE + +# (list) The Android architectures to build for +android.archs = armeabi-v7a + +# (str) Android logcat filters to use +android.logcat_filters = *:S python:D + +# (bool) Copy library instead of making a libpymodules.so +android.copy_libs = 1 + +# (str) The bootstrap to use for android builds +android.bootstrap = sdl2 diff --git a/main.py b/main.py new file mode 100644 index 0000000000000..cfc702bd6cb23 --- /dev/null +++ b/main.py @@ -0,0 +1,153 @@ +""" +My Kivy App — KivyMD + storage manager demo. + +- KivyMD Material Design UI +- INTERNET permission (connectivity test) +- READ/WRITE_EXTERNAL_STORAGE (write test to Downloads) +- MANAGE_EXTERNAL_STORAGE ("All files access" storage manager, opened + from system settings through pyjnius) +- ffpyplayer / ffmpeg availability check (pulled in by buildozer.spec) +""" + +import os + +import kivy +from kivy.uix.boxlayout import BoxLayout + +from kivymd.app import MDApp +from kivymd.uix.button import MDRaisedButton +from kivymd.uix.label import MDLabel + + +def is_android(): + return 'ANDROID_ROOT' in os.environ or os.path.exists('/storage/emulated/0') + + +def downloads_dir(): + """Directory used for the storage write test.""" + if is_android(): + try: + from android.storage import primary_external_storage_path + return os.path.join(primary_external_storage_path(), 'Download') + except Exception: + return '/storage/emulated/0/Download' + return os.path.expanduser('~/Downloads') + + +class MainApp(MDApp): + def build(self): + self.theme_cls.primary_palette = 'Blue' + self.title = 'My Kivy App' + + layout = BoxLayout( + orientation='vertical', padding=20, spacing=12 + ) + + welcome = MDLabel( + text='Welcome to your Kivy App!', + halign='center', + font_style='H5', + ) + + # Shows versions / environment info once checked + self.status = MDLabel( + text='Tap a button to run a test.', + halign='center', + theme_text_color='Secondary', + ) + + self.add_button(layout, 'Check Libraries', self.check_libraries) + self.add_button(layout, 'Test Internet', self.check_internet) + self.add_button(layout, 'Test Storage (write file)', self.test_storage) + self.add_button(layout, 'Manage Storage (All files access)', + self.open_all_files_settings) + + layout.add_widget(welcome) + layout.add_widget(self.status) + return layout + + def add_button(self, layout, text, callback): + btn = MDRaisedButton(text=text, size_hint=(1, None), + height='48dp', on_press=callback) + layout.add_widget(btn) + + def set_status(self, msg): + self.status.text = msg + + # ------------------------------------------------------------------ # + # Button actions + # ------------------------------------------------------------------ # + + def check_libraries(self, *args): + """Verify ffpyplayer (ffmpeg backend) and the SDL2/OpenGL ES window. + Running this at all on Android means SDL2 + OpenGL ES initialized.""" + import ffpyplayer + from kivy.core.window import Window # noqa: F401 (forces GL init) + + self.set_status('Kivy {} ✓ | ffpyplayer {} ✓ (ffmpeg) | ' + 'SDL2 + OpenGL ES ✓'.format( + kivy.__version__, ffpyplayer.__version__)) + + def check_internet(self, *args): + """Quick connectivity test (needs the INTERNET permission).""" + import threading + + def run(): + try: + from urllib import request + with request.urlopen('https://kivy.org', timeout=5) as resp: + msg = 'Internet OK (HTTP {})'.format(resp.status) + except Exception as e: + msg = 'Internet failed: {}'.format(e) + self.set_status(msg) + + self.set_status('Checking internet…') + threading.Thread(target=run, daemon=True).start() + + def test_storage(self, *args): + """Write a test file to the shared Downloads folder.""" + target = os.path.join(downloads_dir(), 'mykivyapp_test.txt') + try: + os.makedirs(downloads_dir(), exist_ok=True) + with open(target, 'w') as f: + f.write('Hello from My Kivy App!\n') + self.set_status('Storage OK: wrote {}'.format(target)) + except PermissionError: + self.set_status('Storage denied — use "Manage Storage" to ' + 'grant all-files access, then try again.') + except Exception as e: + self.set_status('Storage failed: {}'.format(e)) + + def open_all_files_settings(self, *args): + """Open the system 'All files access' (MANAGE_EXTERNAL_STORAGE) + settings screen for this app via pyjnius.""" + if not is_android(): + self.set_status('"All files access" is Android-only.') + return + try: + from jnius import autoclass + + Settings = autoclass('android.provider.Settings') + Environment = autoclass('android.os.Environment') + Intent = autoclass('android.content.Intent') + Uri = autoclass('android.net.Uri') + PythonActivity = autoclass('org.kivy.android.PythonActivity') + + if Environment.isExternalStorageManager(): + self.set_status('All-files access already granted ✓') + return + + package = PythonActivity.mPackageName + intent = Intent( + Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION, + Uri.parse('package:' + package), + ) + PythonActivity.mActivity.startActivity(intent) + self.set_status('Opened storage settings — grant access, then ' + 'run the storage test.') + except Exception as e: + self.set_status('Could not open storage settings: {}'.format(e)) + + +if __name__ == '__main__': + MainApp().run()