File: PC-Phone USB Sync/main.py
#!/usr/bin/env python3 """ ========================================================================================= PC-Phone USB Sync A standalone and Python-coded Android (and PC) app for Mergeall. [Please note: this code was not meant to be published. If you stumble onto it anyhow, ignore the rough edges and snarky bits.] Copyright © 2024 quixotely.com. All rights reserved. License: this file is provided without warranties of any kind, and only for online viewing and vetting. It may not be copied, posted to GitHub, modified, repackaged, or sold. It is a view-only resource. This Python 3.X script file is run as an Android app; a macOS app; Windows and Linux executables; and source code. Only the latter requires Python and Kivy installs. This script's GUI uses the portable Kivy library; the Android app is built with buildozer (which uses p4a); its macOS app and Windows and Linux executables are built with PyInstaller; and Python 3.9 (3.8 on some PCs) is used internally for both GUI and sync code. buildozer builds use the command lines below, and PyInstaller builds use pc-build/build.py on all three PC platforms. On Android, this app runs on Android 8.0 (api 26) and later (tested to A13), and targets Android 12 (api 31) due to Play store rules for new apps. On PCs, this runs as source code broadly, and its 64-bit exes are built on macOS Catalina (universal2), Windows 11, and Ubuntu Linux. Mergeall's former tkinter GUI still works on earlier Androids and other PCs, though their market share is rapidly approaching zero. This app is a free executable on PCs, and runs on Windows, macOS, and Linux; see https://quixotely.com for downloads. The PC versions were added later, but also use the Kivy GUI, and this same code. The Android app is for pay on the Google Play store at a modest cost, with a free trial that expires after a preset number of app runs (more or less; see handle_trial_counter() for subversions+caveats). Dependency: Kivy currently requires Open GL ES 2.0, which has been marked as deprecated by Apple in favor of its proprietary alternative (yes, rudely!). Support for Open GL may also be murky on Windows. The Kivy project has already begun moving to alternatives on macOS (using a compatability layer developed by Google), but in the worst case, Mergeall's own tkinter GUI can be used to run syncs on PCs too. See https://github.com/kivy/kivy/issues/5789. The underlying Mergeall system run by both PC executables and Android apps is open source, and available at learning-python.com/mergeall.html. This app/GUI is not open source, but may become so eventually; it was first released as a premium app, to set it apart from free Android fare. ----------------------------------------------------------------------------------------- ANDROID BUILD CHEAT SHEET [see also ../_HOW.txt for the build saga] [see also pc-build/build.py for PC builds: totally different, PyInstaller] Start code must be main.py (this), can config folder in .spec There is a .buildozer/ in both ~ and ., plus code installed in python /Frameworks buildozer.spec is in . after running "buildozer init" => mod for app Built apps show up in ./bin Kivy ui def: "class Xxx" => file "xxx.kv" MANAGE_EXTERNAL_STORAGE works if in manifest via ./bd.spec file: needs runtime perm foreground services and file providers require extra steps: see notes here + bd.spec MAIN BUILD COMMANDS: # caution: folder name in below's .sh code changed for 1.2 (and 1.3) # set vars here and in buildozer.spec to make 4 apps: full/trial & debug/release # now set just REL* here/ahead for release version: no more trial app [1.3.0] cd ~/Desktop/DEV-BD/apps/PC-Phone-USB-Sync-1.3 (find {., ~/.buildozer} -name ".DS_Store" -print -exec rm {} \;) source _dev-misc/UPLOAD-KEY/set-env-vars.sh (set upload-key vars for signing 'release') buildozer android debug (make apk: can sideload or stream install) (mod REL* here/below for release=True) buildozer android release (make aab uploadable instead of apk) PLUS: # stream install, logcat seperately (mind the version # in the filename!) export ADB=~/.buildozer/android/platform/android-sdk/platform-tools/adb $ADB install bin/usbsync-1.3.0-arm64-v8a_armeabi-v7a-debug.apk $ADB install bin/usbsynctrial-1.3.0-arm64-v8a_armeabi-v7a-debug.apk $ADB logcat | grep python DEV: # build+install+run+logcat: buildozer android debug deploy run logcat | grep python (add "clean" to reset app components: runs long) DEV: # build only (and to see more error msgs): ~/Desktop/DEV-BD/apps/PC-Phone-USB-Sync-1.3$ buildozer -v android debug ETC: buildozer -v android debug deploy run logcat buildozer -v android debug deploy run logcat > my_log.txt buildozer -v android deploy run logcat | grep python ETC: buildozer --help (for all options) Android-SDK/cmdline-tools/latest/bin/apkanalyzer files list bin/usbsync-1.3.0-*-debug.apk Android-SDK/cmdline-tools/latest/bin/apkanalyzer manifest print bin/usbsync-*-debug.apk Android-SDK/build-tools/33.0.1/aapt dump badging bin/usbsync-1.3.0-*-debug.apk ----------------------------------------------------------------------------------------- MORE DEV NOTES **See ../PC-Phone-USB-Demo/main.py for more early notes trimmed here** **And ../HOW.txt plus the ../../{DEV, DEV2}/HOW older files it names** SUMMARY As of rc2, a "clean" build requires 2 manual steps, + 1 if view files in Finder: - [1.2.0] to install a newly targeted android API level (sdk): 1) change target-api setting in ./buildozer.spec 2) get new sdk in ~/.buildozer (buildozer doesn't (!)) ~/.buildozer/android/platform/android-sdk/tools/bin$ export ANDROID_HOME=/Users/me/.buildozer/android/platform/android-sdk ./sdkmanager --sdk_root=${ANDROID_HOME} --install "platforms;android-33" ls ../../platforms => android-31 android-33 3) mod the following TWO build files for new target-api level: ./.buildozer/android/platform/build-arm64-v8a_armeabi-v7a/dists/usbsync/project.properties and likewise for ../usbsynctrial else buildozer uses new api to compile, but doesn't reset it in manifest (!) (avoids "clean" or "appclean" build that refetches new versions of everything) (https://stackoverflow.com/questions/72746288/buildozer-android-api-doesnt-change) - [1.2.0] to force python38: "source ~/use38" and mod ~/.bash_profile to be sure and "py3 main.py" to test app as source - must "find {., ~/.buildozer} -name ".DS_Store" -print -exec rm {} \;" else obscure for version# errs - must edit jnius/env.py file, TWICE - in both arch builds (post make clean) else JNIUS undef error early in build log see ../HOW.txt - must insert fileprovider code, TWICE - in TWO dists manifest templates (post make clean) else Logs OPEN won't workin the app see ./manifest-manual-*/xml MORE BUILD/DEBUG HURDLES - had to declare properties in Main() ref'd in .kv file - had to add .txt to source.include_exts in .spec for help text files in apk folder - had to set android.no-compile-pyo=True in .spec else mergeall config not editable - no-compile-pyo => no-byte-compile-python Nov22 (but still in old spec files!!) - adding '.' to sys.path fails, but os.getcwd() works - the usual 2 JAVA_HOME edits after build clean (see _dev*/demo-app/main.py) - [android:largeHeap="true"] in manifest doesn't help for file loads (java, not ndk) - BIG: had to patch buildozer manifest template for <provider>: see do_logs_open() - had to disable Back close: detecting service running on restarts deprecated+errorprone - maxnumbackups was a mess: 'from' statement, function-arg default, del sys.modules - BIG: mod mergeall.cpall to use forked shutil--skip chmod errs on Android for exFAT+FAT32 - two apps reuires two ids everywhere: file providers, foreground service, etc. - cancel failing update actions for removable folders outside app folder on android 10- - BIG: fix fatal kivy TextInput memory blowup by clearing its cache manually in TAIL+WATCH - aab signing for play store: upload key in _dev-misc, poorly documented and onerous - BIG: fix fatal kivy race condition for ToggleButton weakref dels in on_tab_switch() - fix kivy black TextInput lines, loss of scrolling, slow loading, orange dots, and more - plus lots of other kivy, buildozer, and pyinstaller workarounds not listed here PC PORTS macOS largely worked unchanged x usb drive names => list /Volumes x permissions (incl. full-disk-access) is weakly documented: downplay Windows problems resolved: x usb drive reports as drive_fixed (below) x opens too small (set window size) x Help takes 8secs to open (defer set to next cycle) x weird persistent red/orange dot post btn touch, on macos too (obscure hack) x checkboxes too bright (allow to vary at startup) x emdash and copyright symbols fail in text (use utf encoding) x unicode nfd variants propagated from mac=>win=>mac can fail on mac Linux mostly worked post windows x macos open => xdg-open x join => osjoin (see gotcha below) x usb drive names => scrape mount x RunningOnLinux was also True for android (win size) => fix GOTCHA: 3 "join()" calls in this file worked in the Android app, but NOT when run as source code on PCs! The p4a wrapper code used to launch this file in Android app imported "join" from os, but didn't delete it from __main__'s scope before launching this code. Changed to use "osjoin()" everywhere here, but the wrapper is error-prone and should be improved. The p4a startup code (in github, builds): python-for-android/pythonforandroid/bootstraps/common/build/jni/application/src/start.c PC BUILDS macOS buildozer was a fail use pynstaller --onedir so opens fast sans splash screen pyinstaller worked after add .kv to datas use '.' for file --add-datas, else a subdir need to set app title/icon and window icon here, else titles drops ' _', kivy icon app seems to lose Document folder permissions on reinstall; see Settings later: required .spec file for verion# BUNDLE arg available in spec file only later: add prompt for full-disk-access perm on run1, similar to android a-f-a later: built universal2 binary exe, using py 3.10 and pillow wheels combo on macbook Windows add special ctypes code for dpi scaling, else large/blurry (worse than tkinter) need to use spec file with Tree()s for kivy-deps, else no window provider add special code to close the splash screen, else never goes away need to restore builtins.print in thread wrapper: mergeall.py resets for frozen exes exes are suddenly >2G, killing build: exclude pc-build/ from tempsrc, copy its files up add special code for kivy logging error: writes to newly None sys.std* if --windowed Linux does NOT use spec files and kivy-deps like Windows; Kivy docs are deceptive on this! the .kv file is looked up ONLY in _MEI* temp unzip folder: must --add-data windows looks up .ks in _MEI* too, but seems to add to exe automatically lots of giant modules are pointless: excludes shrunk size form 200M to 40M running in virtualenv to reduce size doesn't help: shot up with undef installs plus all the Windows WSL2 Linux shenanigans; see Tech-Notes.html, feb24+. ----------------------------------------------------------------------------------------- TODO (rough, now dated: see _dev-misc/todo2.txt for laters) See also _dev-misc/todo.txt, Note20 memo n run ma as imported pkg to avoid os.cdir()? [kivy impact unknown]? x allow #logfiles-saved and #bkp-saved to be configs? x help-message.txt x icon x recode gui... x persist paths... x threads in gui (exec or import/recode)... ? disable file selection only (what?)... n animate disabled-image for Main buttons... Button().background_disabled_normal = image n bkgrnd and power-management issues, kills, and throttling power setting? Foreground Service? WorkManager?... thread & service run if left/pause and screen off states x is wakelock really held? yes: scripts run with screen off x is thread really immune to process kills? probably - it's not a child process x does thread run if app paused? yes: and at same speed as fg and termux x resize gui for keyboard?... https://stackoverflow.com/questions/36770050/properly-resize-main-kivy-window-when-soft-keyboard-appears-on-android#61037979 x why does icon/list swap work?.... => because kivy keeps both views, swaps, and updates redundantly x why doesn't ActionButton@Button work?... => because it clashes with a Kivy class name (!) - disable app-spec content backup to cloud?... - punt on app-specific altogether? (it's complicated, requires filename fixer for android) - reconsider cruft-pattern edits? (complex: skip+keep lists + case, pattern syntax) x fileprovider for open x foreground service for action runs x consider supporting older android with prior permissions scheme; usb differs x config tab, fontsize etc x catch/disable app exit if thread running? back button? subprocess differs? did for back button if script (thread|process) running; recent swipe can't catch x are mergeall modules compiled to bytecode? YES - in __pycache__, with .cpython-39.opt-2.pyc suffix x scrolled run output: \n dropped, ascii() quotes, mono font, scroll to end, speed but now moot: abandned for animated gif + Log TAIL/WATCH on demand x configs: color chooser, etc x port to macos, windows, linux x build pc exes x fonts in icon view in filechooser: FIXED in .kv n consider more responsive layout for landscape phones; PUNT (for now) x userguide x domain name, website x signing/store x trial version (see _dev-misc/todo2.txt for laters) ========================================================================================= """ #@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ # IMPORTS1 + PC PLATFORM TWEAKS #@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ # Python (see also GOTCHA above) import sys, os import pickle, glob, time, re, queue, webbrowser, json, datetime, threading, platform osjoin, osexists = os.path.join, os.path.exists # Python 3.X+ only assert(int(sys.version[0]) >= 3) """ ----------------------------------------------------------- Platform: we mostly care about the app/exe being run here, because a Python is bundled, and source is not distributed. Per Python's sys.platform: Linux means native, WSL on Windows, or Android don't care about WSL: must run Windows or Linux exe Cygwin means Windows, but using its own Python don't care about Cygwin: must run Windows exe, not code Linux must be reset to mean non-Android Linux only, else some code would erroneously fire on Android too (e.g., window size). Android detection prior to Python 3.7 is partly heuristic: any(key for key in os.environ if key.startswith('ANDROID_')). Python 3.7+'s sys.getandroidapilevel() is set at build time. 3.7+ req ok: app uses 3.9+ for Android (3.8+ among all PCs). ----------------------------------------------------------- """ RunningOnAndroid = hasattr(sys, 'getandroidapilevel') # py 3.7+, but Android uses 3.9+ RunningOnMacOS = sys.platform.startswith('darwin') # intel and apple m (rosetta?) RunningOnWindows = sys.platform.startswith('win') # Windows py, may be run by Cygwin RunningOnCygwin = sys.platform.startswith('cygwin') # Cygwin's own py, run on Windows RunningOnLinux = sys.platform.startswith('linux') # native, Windows WSL, Android RunningOnLinux = RunningOnLinux and not RunningOnAndroid # Linux ONLY: else true on Android too # [1.1.0]+ (feb24) Windows WSL2 Linux only wsl2sig = 'microsoft-standard-WSL2' RunningOnWSL2 = RunningOnLinux and platform.uname().release.endswith(wsl2sig) if RunningOnWindows: """ ----------------------------------------------------------- [1.1.0]+ (nov23) Move win32 imports to top of script so they will run once at script startup, instead of later in drive-name method storage_names_and_paths_removables(). This avoids import aborts if/when Windows 10+'s Storage Sense wrongly deletes the PytInstaller --onedir _MEI* temp folder prematurely (it was seen to do so once). That abort reflects a nasty bug in Storage Sense: temp files of still-running programs should NEVER be silently removed! The abort was also exceedingly rare: it was observed just once, when an odd system state caused the abort in an instance that had not yet opened a folder chooser before a restart. Still, the workaround here is easier than explaining all this to users... Note: mergeall/ scripts and imported modules are immune to this, because they are loaded from the install unzip folder (.exe's host), not the _MEI* per-run unzip folder. Code ahead cds to the install folder during startup, and mergeall/ is accessed from '.' therafter. Also note: win32 modules are included in the exe despite no --hidden-import. ----------------------------------------------------------- """ # get drive roots, network paths [after 'pip3 install pywin32' on windows] import win32api, win32file, win32wnet if RunningOnWindows: """ ----------------------------------------------------------- Like tkinter, Kivy does not do DPI saling well on Windows, which makes GUIs open large and blurry! This code must be run before importing kivy and addresses the issue, but is provisional, pending a fix in SDL2 layers in the Kivy stack. Note that this applies to python.exe for source-code runs, but to the frozen executable for its run: per-process call. Used for tkinter: windll.shcore.SetProcessDpiAwareness(1) https://github.com/kivy/kivy/pull/7299 https://github.cwindll.user32.SetProcessDpiAwarenessContext(c_int64(-4))om/kivy/kivy/issues/3705 https://learning-python.com/post-release-updates.html#win10blurryguis ----------------------------------------------------------- """ from ctypes import windll, c_int64 windll.user32.SetProcessDpiAwarenessContext(c_int64(-4)) if hasattr(sys, 'frozen') and (RunningOnWindows or RunningOnLinux): """ --------------------------------------------------------------- Yet another Kivy open bug workaround! In --windowed mode, Kivy logging writes to sys.stdout/err that are now set to None by PyInstaller instead of dummy objects, thereby triggerring a recursion-limit error. Doesn't happen if --windowed not used. This must be called this early in the script, else logs happen. Likely to be fixed very soon; but how wasn't this tested?... https://github.com/kivy/kivy/issues/8074 https://github.com/pyinstaller/pyinstaller/issues/7329 --------------------------------------------------------------- """ os.environ['KIVY_NO_CONSOLELOG'] = '1' # this is call-stack depth, and defaults to 1000 on Windows # NO: pyinstaller code was stuck in a loop, this didn't help # sys.setrecursionlimit(2000) if hasattr(sys, 'frozen') and (RunningOnWindows or RunningOnLinux): """ --------------------------------------------------------------- Tell the PyInstaller splash screen to close (else it doesn't!). The splash screen isn't supported on macOS, but the --onedir build for macOS opens quickly, unlike Windows/Linux --onefile. Android apps get splash screens from buildozer that just work. Nice feature, but why in the world doesn't this auto close?... [1.1.0]+ (feb24) doc: the splash screen (only) uses tkinter, which requires Tcl/Tk to be present. This causes problems on Windows WSL2 Linux - users must manually install Tcl/Tk to use this app there, because PyInstaller assumes they're present, and they're not in WSL2 Ubuntu. There was also another obscure issue on desktop Linux with Tcl/Tk version numbers. Plus all the weirdness of not auto closing; is this really worth it? --------------------------------------------------------------- """ import pyi_splash # Update the text on the splash screen? pyi_splash.update_text('Loading program...') # currently does nothing... # Close the splash screen. It does not matter when the call # to this function is made; the splash screen remains open until # this function is called or the Python program is terminated. # # pyi_splash.close() # see next function, called later def close_pc_splash_screen(): # run this in App.on_start() instead, to wait for window if hasattr(sys, 'frozen') and (RunningOnWindows or RunningOnLinux): import pyi_splash pyi_splash.close() if hasattr(sys, 'frozen') and (RunningOnMacOS or RunningOnWindows or RunningOnLinux): """ --------------------------------------------------------------- Set up runtime cwd context for frozen PC executables. PyInstaller unzips --onefile exes and their added data to a temporary folder, _MEI*, which is deleted after app exit. This differs from the app's install folder, where the download is unzipped. In this context, this app must arrange access to both persistent and non-persistent data items by cwd or other: - shipped mergeall/ subdir - modified mergeall/mergeall_configs.py module - shipped help and about message files - created run-counter and configs-pickle files - shipped usbsync-anim.gif used for image in gui - created logfiles and UNDO backups - shipped .kv GUI-def file, not handled auto! - shipped usbsync-pc/, PCs must set as icon in .py Some of these these may not work in _MEI* temporary unzip dir, because they are changed and must be retained between app runs: - mergeall/ scripts are loaded as source code from its folder, so cannot be .pycs; never change, so can be in either install or _MEI*; and are already open source, so need not be hidden. Some modules in mergeall/ are also imported here (backup, cpall). - mergeall/mergeall_configs.py is auto-changed by this script for GUI settings. It can be in _MEI* iff it is changed on _every_ Main-tab action run; else it must be in install (or elsewhere). Because this must be accessible, all of mergeall/ must be too. - Help and about files are read-only. They could be in install or _MEI*, but might as well follow the same policy as mergeall/. - Run-counter and configs-pickle files cannot be in _MEI*, because they must be changed and retained between runs. They can be in the install dir, except on macOS which maps them to ~/Library because apps may not be able to write own folders (tbd). These might also be mapped to ~/Documents everywhere, but are not (yet?). - The gif is read only and can be in any cwd (install or _MEI*); and the .kv is read only (and optional, if its code is in .py). - usbsync.png is used at build time (buildozer, pyinstaller), but also for .py icon calls required on PCs (else kivy icon). UPDATE: PCs now use prebuilt icons in usbsync-pc/, not pyinstaller. - logfiles are mapped to ~/Documents everywhere and so are moot. Other: SYNC backups are in TO/, and so are also moot here. To handle these cases, this app uses these policies for PC builds: For macOS: Since PyInstaller builds an app folder anyhow, use --onedir; use --add-data to store all data items alongside the executable in the app folder; and cd to sys.executable's dir (the install unzip's <app>/Contents/MacOS) on startup for cwd-relative access to mergeall/, help, about, gif, and .kv file. The app dir persists between runs, and is assumed changeable by app. This gives persistence for mergeall/mergeall_configs.py, as well as access for help, about, and gif loads. The run-count and configs-pickle files are mapped to ~/Library/appname. This also avoids lags for startup unzips sans splash sceeen on macos. For Windows and Linux: Store data items alongside the --onefile single-file executable in a manually created zipfile, and cd to sys.executable's dir (the install's unzip) on startup for cwd-relative paths (like Mergeall). These items will not unzip to _MEI*, and hence will persist between runs as needed. There is a lag for the unzip at app startup, but these platforms now post a splash screen. Exception: Linux, ONLY, does not look for the .kv file in cwd, but ONLY in the _MEI* temp unzip folder. On Linux alone, must add it via --add-data and can skip copying it to install folder. UPDATE: per more tests, Windows exes run if the .kv is not present in cwd (install folder) and is not added via --add-data, so it must be adding it to the exe at build time. macOS requires that the .kv be added by --add-data on --onedir mode, else the app won't open (and --onefile on macOS takes ~12 seconds to open with no support for a splash screen, vs 1~2 for --onedir mode). Kivy's folder-selection code is way too whack to plumb for answers, and its handling of .kv files in frozen executables is an undocumented, convoluted mess. PC builds work; punt! macOS alt: there's a chance that the app folder won't be writeable by the app. If so, use --onefile to build and unzip to _MEI*; _always_ mod mergeall/ mergeall_configs.py for numbackups on each Main action run in this context ONLY, to make its persistence moot; and cd to __file__ (_MEI*) on startup for cwd-relative access to mergeall/, help, about, and gif. The run-counter and configs-pkl files are already mapped to ~/Libraries/appname for macOS only. UPDATE: not required - app can mod mergeall/mergeall_configs.py. The chdir here makes an empty __file__ dir map to install dir. It precludes any relative paths in cmdline args to frozen exes, but this is moot, as exes here don't access items in users' cwds. Mods here aren't run for either source code or the Android app, which both keep working normally and as is. For more background and another PyInstaller example, see Mergeall's fixfrozenpaths.py. SEE ALSO: pc-builds/build.py applies these policies in builds. --------------------------------------------------------------- """ # sys.executable is the frozen exe for --onefile and --onedir # sys.argv[0] is the path used in the launch command, poss rel # sys._MEIPASS is the TEMPORARY uzip folder for --onefile # __file__ is sys._MEIPASS+script for --onefile, else install folder exepath = sys.executable exedir = os.path.dirname(os.path.abspath(exepath)) # install folder os.chdir(exedir) # '.' for all extras # not required: this + launcher mod path based on abs(cwd) # sys.path.append(exedir) # for ma/configs .py import #@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ # IMPORTS2 #@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ # app from run_script_as_thread import run_script_thread_wrapper, Queuer, Flusher from run_script_as_process import run_script_process_wrapper # auto-run as a script by p4a service support: import for error check only if RunningOnAndroid: from run_script_as_service import run_script_service_wrapper # kivy: GUI from kivy.app import App from kivy.uix.floatlayout import FloatLayout from kivy.uix.popup import Popup from kivy.uix.button import Button from kivy.uix.label import Label from kivy.uix.textinput import TextInput from kivy.uix.togglebutton import ToggleButton from kivy.uix.checkbox import CheckBox from kivy.uix.boxlayout import BoxLayout from kivy.properties import ObjectProperty, StringProperty, NumericProperty from kivy.clock import mainthread, Clock from kivy.core.window import Window # instance is app.root_window import kivy.metrics from kivy.config import Config from kivy.cache import Cache from kivy.uix.filechooser import FileSystemLocal # hangware [1.1.0]+ (feb24) # pyjnius: Java interface if RunningOnAndroid: from jnius import autoclass, cast # p4a: helpers if RunningOnAndroid: import android.storage #from android.storage import app_storage_path #from android.storage import primary_external_storage_path from android.runnable import run_on_ui_thread from android.config import ACTIVITY_CLASS_NAME from android.broadcast import BroadcastReceiver else: run_on_ui_thread = lambda func: func # no-op decorator for PCs # old-style storage perms if RunningOnAndroid: from android.permissions import request_permission, check_permission, Permission #@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ # GLOBALS #@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ # version also redundantly in buldozer.spec + pc-build/{_macOS*.spec, _winver*}; # version string for Play can be anything, x.y.z allows simple patches to x.y; # buildozer (really, p4a) builds versionCode from it: '10'+sdkmin+shiftedVERSION; # Search on [VERSION] to find mods in code; [VERSION]+ is select platforms only; # History: [1.0.0] May 2023; [1.1.0] June 2023; [1.1.0]+ later; [1.2.0] March 2024; # [1.3.0] September 2024: make free and drop trial version, update docs accordingly. APPNAME = 'PC-Phone USB Sync' # used for assorted folder names, etc. VERSION = '1.3.0' # versionName in manifest, inserted in About PUBDATE = 'September 2024' # inserted in About by on_start() too [1.1.0] # Android trial app: mod TRIAL_VERSION + buildozer.spec [title, package, presplash, icon] # per handle_trial_counter(), app data clears and installs can subvert run counter; # use 'buildozer android release' to make release aab of both, use '...debug' for apk; # this works because this file's code is copied into app as it was at build time here; TRIAL_VERSION = False # True for trial: limited #opens TRIAL_APP_OPENS = 10 # 5 seems too few, but reinstalls should be a bit onerous # distinguish android trail app where it matters (only) [1.1.0] APPNAME_FULL_OR_TRIAL = APPNAME if not TRIAL_VERSION else APPNAME + ': Trial' # release aab on Android: mod to disable trace print()s to logcat (per Play); # change manually - can have release versions of _both_ full and trial app; # if `buildozer android release` ensures this, it's a very well-kept secret; # print()s in nested mergeall/ code are routed to logfile in all run modes; # [1.3.0] trial version is gone, so this is the only per-build mod now required RELEASE_VERSION = False # True for release: don't trace # global font size: it's complicated; see set_font_size() # [1.1.0]+ (oct23) add a fudge factor for linux - init fontsize is _very_ # small [caveat: this is risky - tested on just one linux device so far]; # # also changed for linux in [1.1.0]+: # - (sep23) scale initial window size and add fudge factor too, ahead # - (oct23) executable rebuilt on ubuntu22 for lib-skew issue on 20 # # per kivy docs: sp() for fonts (uses user font settings), else dp(); # note: RunningOnLinux constant means linux only, and not android; lx = 5 if RunningOnLinux else 0 # [1.1.0]+, +3 still seems too small FONT_SIZE_DEFAULT = int(kivy.metrics.sp(15+lx)) # kv default=15sp, scaled for device+user # items in app-install folder (not app-private) (no user access on android) HELP_FILE = 'help-message.txt' ABOUT_FILE = 'about-template.txt' TERMS_OF_USE_FILE = 'terms-of-use.txt' MERGEALL_PATH = 'mergeall' SERVICE_BREADCRUMB = 'latestservice.txt' # no longer used # items in app-private folder (not app-install) (no user access on android) SETTINGS_FILE = 'settings.pkl' RUNCOUNT_FILE = 'runcounter.txt' # logfiles in Documents folder (android: shared-storage, pcs: ~, user can access; !trial) LOGS_SUBFOLDER = APPNAME # spaces ok+common; allow in OPEN/EXPLORE; .replace(' ', '_') # [1.2.] now used for multiple greps (globs) in logfiles folders: ensure same LOGFILE_NAME_PATTERN = 'date*-time*--*.txt' # macos app-private folder in ~/Library (avoid app-bundle folder, user can access, !trial) MACOS_APPPRIV_SUBFOLDER = APPNAME # items in TO/__bkp__ sync backups folder (user can access) UNDOABLE_FILE = '__undoable__' # see also '--UNDONE' renames # values of root.script_running (0 is False, else True) NOTHING_RUNNING = 0 # running as thread or service or process?: for app close THREAD_RUNNING = 1 # as thread only: for trace() - don't print() to streams SERVICE_RUNNING = 2 # as service only: for on_pause/resume() - mod msg receiver? PROCESS_RUNNING = 3 # as service only: for giggles? # thread coding choices (temp, probably) STATUS_ANIMATION, STATUS_SCROLL = True, False # save Main() instance in global (because self.trace is too much to type...) guiroot = None def trace(*args, **kargs): """ ----------------------------------------------------------- Don't print if thread running: streams sent to action's logfile (formerly, line queue then logfile; thread mode now uses thread Flusher which writes to logfile directly). Prints go to adb logcat on Android, console on macOS?, etc - for developer viewing only. Alt: logcat direct? In addition: frozen Windows/Linux exes mod builtin print, which raises excs for flush*2. Avoided by THREAD_RUNNING: builtin print is changed by in-process mergeall.py code run in a thread, but restored by thread wrapper on mergeall exit before script_running is cleared (and before the clearing code is scheduled to run!). See run_script_as_thread.py. UPDATE: mergeall/mergeall.py now omits flush=True if one is sent here, because excs still happened during tab-switch callbacks run at odd times (thread/GUI timing is complex). Moot if not THREAD_RUNNING: standard streams in different process for foreground service (and unused simple process), so app can print freely here and print reset has no impact. Don't print trace messages in release builds for Android. That means there's nothing to go on for errors, but most users won't be able to run logcat anyhow, and Google Play chides about trace messages (and is douchefully dogmatic). ----------------------------------------------------------- """ if RELEASE_VERSION: return if not guiroot.script_running == THREAD_RUNNING: # don't print to logfile print('(PPUS)', *args, **kargs, flush=True) # don't flush*2 if frozen def trace2(*args, **kargs): """ ----------------------------------------------------------- NOT CURRENTLY USED, but can be used for app debugging. To trace while action thread runs; also pass trace=True to run_script_thread_wrapper() so stderr is not rerouted to line-queue Pipe. Don't double flush in frozen Windows and Linux exes: they already reset builtin print to do so. Don't print trace messages in release builds for Android. [1.1.0]+ "RunningOnWindows or RunningOnLinux and hasattr(..)" didn't look right and was corrected (py 'and' binds tighter than 'or'), but this is moot and harmless - function unused, and the need to omit flush=True was lifted (see trace() above; was this why there were still unexplained print exceptions??); ----------------------------------------------------------- """ if RELEASE_VERSION: return if (RunningOnWindows or RunningOnLinux) and hasattr(sys, 'frozen'): # [1.1.0]+ (oct23) print('(PPUS)', *args, **kargs, file=sys.stderr) else: print('(PPUS)', *args, **kargs, file=sys.stderr, flush=True) class _PopupStack: """ =========================================================== A simple stack for popup dialogs that may nest. Kivy coding structure makes it easiest to save Popups for later closes, but Popups may nest here (e.g., an info_message while a folderchooser is open, though this may have been impossible in earlier code). UPDATE: Button double-tap nonsense: 1) Pops of _empty_ stack triggered excs and app aborts when dialog close buttons were double tapped on all platforms. This specific issue is fixed by checking for empty here, but double taps create other issues. 2) For one, double taps can close _two_ dialogs stacked atop each other at once. To avoid this, all dismiss calls now pass the Popup's content, and dismiss here skips the call if passed contents do not match that of the stacked Popup. This avoids closing overlaid Popup on double taps. 3) Now also uses auto_dismiss=False on all Popup() so Popup not dismissed without a pop() here. Else, bogus auto-dismissed entries remain stacked and are dismissed on later dbl taps (though this seems to be harmless). Could keep auto-dismiss by catching on_dismiss and doing pop() in catcher, but taps outside multi-button popups are ambiguous and seem error-prone in practice anyhow. More generally, a double tap on _any_ button may trigger its callback twice... This seems a Kivy misfeature (bug!), but hasn't been an issue in testing. If can only matter if event is fired because the widget still active; the Popup case is probably timing - Button event beats the dismiss() event. Alt: disable Button double-tap instead? =========================================================== """ def __init__(self): self.popups = [] def push(self, popup): self.popups.append(popup) # stack Popup object def empty(self): return not self.popups def pop(self): if not self.empty(): return self.popups.pop(-1) # remove+return top def top(self): if not self.empty(): # avoid dbl-tap excs return self.popups[-1] def open(self): if not self.empty(): self.top().open() # display topmost Popup def dismiss(self, content): if not self.empty(): if self.top().content == content: # avoid dbl-tap closes self.pop().dismiss() # close topmost Popup class FileSystemLocalNoHang(FileSystemLocal): """ =========================================================== [1.1.0]+ (feb24) Wrap methods of the Kivy FileChooser's FileSystemLocal interface class (os module calls, mostly) in thread timouts. Else the chooser can stall (block and possibly hang) as users navigate by tapping its UI. Network drives, cameras, etc., may go offline, especially on Windows, causing raw, unwrapped calls to block indefinitely. This happens deep in the Kivy FileChooser code, but its file_system hook supports mods. Code here fixes the chooser widget; filesystem calls in the app itself were wrapped in thread timeouts earlier (the 1.1.0 release in Jun23). Coding notes: this doesn't call os functions directly, because Kivy's FileSystemLocal.is_hidden() uses tight magic to access and use a private win32 call on Windows. guiroot is the global Main instance: see PCPhoneUSBSync.on_start(). This adds a thread per chooser nav tap, but PCs and phones are fast. TBD: is this useful only, or at least primarily, on Windows? If so, could avoid one thread per os-module call elsewhere. To date, this is the only platform where nav hangs have been seen, though PCs and phones are both awfully fast... =========================================================== """ def listdir(self, filepath, warn=True): """ called for both test and result: warn user """ trace('NoHang.listdir') listed = guiroot.try_any_nohang(super().listdir, filepath, onfail=None, log=False) if listed != None: return listed # empty or not (no exc or timeout) else: trace('Skipping dir: listdir') if warn: guiroot.info_message('Cannot access folder.', usetoast=True) raise OSError # kill dir in filechooser (run in try) def getsize(self, filepath, warn=False): """ may be used if is_dir fails, but rare """ trace('NoHang.getsize') getsize = guiroot.try_any_nohang(super().getsize, filepath, onfail=None, log=False) if getsize != None: return getsize # dir or not (no exc or timeout) else: trace('Skipping file: getsize') if warn: guiroot.info_message('Cannot access file.', usetoast=True) raise OSError # kill dir in filechooser (run in try) def is_hidden(self, filepath, warn=False): """ win32 privates used in super call numerous times: don't warn user """ #trace('NoHang.is_hidden') ishidden = guiroot.try_any_nohang(super().is_hidden, filepath, onfail=None, log=False) if ishidden != None: return ishidden # hidden or not (no exc or timeout) else: trace('Skipping dir: is_hidden') if warn: guiroot.info_message('Cannot access folder.', usetoast=True) raise False # kill dir in filechooser (run in try) def is_dir(self, filepath, warn=False): """ called an insane number of times: don't warn user """ #trace('NoHang.is_dir') isdir = guiroot.try_any_nohang(super().is_dir, filepath, onfail=None, log=False) if isdir != None: return isdir # dir or not (no exc or timeout) else: trace('Skipping dir: is_dir') if warn: guiroot.info_message('Cannot access folder.', usetoast=True) return False # kill dir in filechooser fileSystemLocalNoHang = FileSystemLocalNoHang() # set in chooser on create #@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ # KIVY INTERFACE #@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ class MainPathPickDialog(FloatLayout): """ ======================================================= The file-chooser dialog's callbacks. Also defined with <> instance rule in .kv. self argument implied in .kv file: root=instance. Class and its properties _must_ by defined in .py. ======================================================= """ # these become self attrs onpick = ObjectProperty(None) # refs callback in Main, run in .kv oncancel = ObjectProperty(None) # all args to class _must_ be declared here pickstart = StringProperty() # set here, used in nested fc: root.pickstart rootpath = StringProperty() # same as dialog.ids.filechooser.rootpath onviewmode = ObjectProperty() # icon|list from/to the Config tab (persistent) class InfoDialog(FloatLayout): """ ======================================================= The info-message dialog. Also defined with <> instance rule in .kv. self argument implied in .kv file: root=instance. Class and its properties _must_ by defined in .py. ======================================================= """ message = StringProperty() # passed here as args => root properties oncancel = ObjectProperty(None) # refs bound callback in Main, run in .kv class ConfirmDialog(FloatLayout): """ ======================================================= All in .kv, but must declare here too ======================================================= """ message = StringProperty() onyes = ObjectProperty(None) onno = ObjectProperty(None) class ColorPickDialog(FloatLayout): """ ======================================================= All in .kv, but must declare here too ======================================================= """ onpick = ObjectProperty(None) oncancel = ObjectProperty(None) class TrialEndedDialog(FloatLayout): """ ======================================================= All in .kv, but must declare here too ======================================================= """ message = StringProperty() oncancel = ObjectProperty(None) class ConfirmNameDialog(FloatLayout): """ ======================================================= All in .kv, but must declare here too ======================================================= """ message = StringProperty() onrunreport = ObjectProperty(None) onrunupdate = ObjectProperty(None) oncancel = ObjectProperty(None) class ConfigCheckbox(CheckBox): """ ======================================================= Ref in .kv, def in .py: need to set color per patform [r, g, b, a] for image tinting - too dim on macos only Default is [1, 1, 1, 1], which works well elsewhere ======================================================= """ color = [1, 3, 5, 4] if RunningOnMacOS else [1, 1, 1, 1] class TextInputNoSelect(TextInput): """ ======================================================================== Workaround for a Kivy text selection bug on Android. Or: The struggles to disable persistent handles and pointless selections. Kivy currently has a nasty bug which makes selection handles linger on in the GUI on Android - both after popup dismiss, and after tab switch. This is an issue only for _readonly_ text, as handles go away on either keyboard minimize or tab switch; chooser paths and log text are readonly. To workaround, first disabled copy, handles, and buttons in .kv file. This works, but user actions can still select the text pointlessly. Selection was first addressed by cancel_selection on tab switch (popup is auto by recreate), but it's still subpar to allow useless selections. The final workaround overrides selection methods in the TextInput class here (it didn't work in .kv) to disale selections in full. This makes the earlier fixes moot, and brings an end to all the troubles... ======================================================================== """ def __init__(self, **kwargs): super().__init__(**kwargs) # try 1: prevent changes, and selection handles and bubbles (.kv) #self.readonly = True # else keyboard still covers (iff font?) #self.allow_copy = False # else select handles on other tabs... #self.use_handles = False # prevents handles hell... #self.use_bubble = False # prevents action-bubble popups... # nuclear option: prevent selections altogether (here) def on_double_tap(self): pass # trace('on_double_tap\n') def on_triple_tap(self): pass # trace('on_triple_tap\n') def on_quad_touch(self): pass # trace('on_quad_touch\n') def long_touch(self, dt): pass # trace('long_touch\n') """ NO LONGER USED: in tab class LogfilePickDialog(FloatLayout): "" ======================================================= All in .kv, but must declare here too ======================================================= "" pickstart = ObjectProperty(None) onpick = ObjectProperty(None) oncancel = ObjectProperty(None) """ """ # TBD class SectionLabel(Label): def __init__(self, **args): super().__init__(**args) self.padding = [8, 8] valign = 'center' self.text_size = root.width, None self.size = self.texture_size class InfoLabel(Label): def __init__(self, **args): super().__init__(**args) #self.padding = [8, 8] valign = 'left' self.text_size = self.texture_size class LeftButton(Button): def __init__(self, **args): super().__init__(**args) """ #@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ # MAIN (ROOT) GUI CLASS #@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ class Main(FloatLayout): """ ======================================================= The main/start window's callbacks. Corresponds by name to root rule in .kv. self argument implied in .kv file: root=instance. ======================================================= """ # kivy properties from_path = ObjectProperty(None) # from/to path display+edit fields in GUI to_path = ObjectProperty(None) logfile_path = StringProperty() # referenced in kv file during build() about_message = StringProperty() # default initial value is '' (a False) help_message = StringProperty() if STATUS_SCROLL: run_output = ObjectProperty(None) # bound to same name in .kv via TextInput's id #======================================================================= # MAIN-TAB FILE CHOOSER #======================================================================= def do_main_path(self, kind, kindid): """ ---------------------------------------------------------------------- Run from Main's FROM|TO buttons to pick a content path. kind = 'FROM'|'TO' for use in popup dialog label only. kindid = 'frompath'|'topath', which gives access via self.ids[] to pathfield = text display for the selected folder kind in Main tab. The last of these is used to initialize the selection-area display: the picker opens at the last Main pathfield setting for kindid, unless it's not a valid dir, in which case the picker opens on shared (here). Dynamically adds drive-name buttons after the standard storages; on taps, reset_main_picker_path() changes picker content dynamically. This just works in the folder chooser (but it also has multiple bugs). ROOT is complicated. It's unclear if the app can simply access '/' folders sans su commands on rooted phones (Termux "ls" required su for USB), and there's no root support in the kivy FileChooser. Enable iff Config, show '/' if access else api's /system, and avoid permission excs by setting dialog.rootpath to avoid '..' navigation. Illegal folders (e/g. '/', '/storage') throw excs if reached by '..'. Changes in the chooser's viewmode in the popup modify the popup's display, but also the Config tab's associated settings. The Config tab's settings are used for later restarts and persistence saves. ---- UPDATE: fix icon-view clipping for bigger fonts by copying template from kivy.style to .kv and modifying the label; this is awful, but there are no ids or other hooks for doing this; improve me, Kivy! UPDATE: make red storage-name buttons radio toggles, so it's clearer to users what is being displayed. This is probably moot once users get used to path names on their devices, but seems ambiguous at first. UPDATE: use child-list crawls here to nuke the "Size" label in list view, which is bogus for a folder chooser, and gets truncated to just "e" on some phones and font sizes (!). The fix simply sets the label to '' which may waste its space, but there are no builtin ids or hooks, and a core-code copy+mod seems worse (see the icon-view font fix in .kv). Children are reverse order of kivy-lang defs: see FileChooserListLayout in https://github.com/kivy/kivy/blob/master/kivy/data/style.kv (builtin) UPDATE [1.1.0]+ (feb24): subclass the chooser's file-system interface class, to wrap its calls in thread timeouts to avoid blocks and hangs. Else navigation taps can stall deep in the chooser widget itself. ---------------------------------------------------------------------- """ #--------------------------------------------------------------------- # prelims: get storage name/paths, make dialog #--------------------------------------------------------------------- # user may have passed on open or later if not self.get_storage_permission(): # confirm permission or ask again return # had to reask user: user must retry # get storages; shared is primary, appspec is for android only # CAUTION: label_path_string() mimics some of this logic [1.1.0] shared = self.storage_path_shared() appspec = self.storage_path_app_specific() drives = self.storage_names_and_paths_removables() # do before root for Windows root = self.storage_path_root() docs = self.storage_path_documents() # TBD: not curently shown by chooser # view-style utils def initstyle(): """ use Config tab style on first open (used ahead) the popup sets config buttons, but not vice versa """ return 'icon' if self.ids.mainchoosericon.state == 'down' else 'list' def changestyle(viewmode): """ mod Config tab style on chooser style button press yes, both buttons must be set, despite radio group note: these are Config-tab's buttons, not chooser's """ style = str(viewmode) # now moot self.ids.mainchoosericon.state = 'down' if style == 'icon' else 'normal' self.ids.mainchooserlist.state = 'down' if style == 'list' else 'normal' # init display to from|to path in Main tab (or shared, if run1 or drive absent) pathfield = self.ids[kindid] # naive: pickstart = pathfield.text if os.path.isdir(pathfield.text) else shared # avoid chooser exc: prior pick may have been dir but no permission; # probably should check on Pick, but actions already do before runs """ try: os.listdir(pathfield.text) # might hang... except: pickstart = shared # unmounted, permissions, edited else: pickstart = pathfield.text # prior pick still present and valid """ # same, but use timeouts to avoid lags for inaccessible drives [1.1.0] listed = self.try_listdir_nohang(pathfield.text) if listed: pickstart = pathfield.text # accessible: prior pick still present and valid else: pickstart = shared # hung, or fail (unmounted, permission, manual edit) # new chooser: see also .kv picker = MainPathPickDialog( pickstart=pickstart, # start navs here rootpath=pickstart, # stop up-navs here onviewmode=changestyle, onpick=lambda path, popupcontent: self.do_main_path_pick(kindid, path, popupcontent), oncancel=self.dismiss_popup) #----------------------------------------------------------------------- # set up-navigation cutoff for initial path in chooser # # allow '..' nav from last-run start path copied from Main, but only # up to its storage-type root, if any (+1 '..' for paths in app-spec); # else '..' navs above roots trigger silent exceptions in chooser; #----------------------------------------------------------------------- # use absolute path comparisons: user may enter anything manually [1.1.0] abspickstart = os.path.abspath(pickstart) if pickstart == pathfield.text: # copied over? topmost = None # no cutoff #------------------------------------------------------------------------ # android appspec first: it's nested in shared, and ../.. throws exc; # also sort drives by decr path len: usb nested in os on linux [1.1.0]; # # [1.1.0]+ (feb24) what?; this may reflect an erroneous mount of C: to # /media/me, which hid USB drives that ubuntu auto-mounts to /media/me/XXX: # the effect nested USB in C:'s mount (!), and only one might be usable; # # either way, match prefix by longest = most specific/nested first, # to handle nesting on all platforms as well as possible; user can # always tap a root button to restart (and trigger similar code ahead); # # assumes no drive will be nested in shared=home on Unix (macos+linux): # unikely, imposs on android+windows, and just a few extra ..s if wrong; # # a bit gray here: can't tell which storage was used to select prior path; # here the effect may be to limit .. navs to a lower nested storage root, # but this roughly corresponds to button preselect ahead: most specific; # # NIT: in hindsight, it seems that calling out appspec before shared is # not needed here, because the decreasing-path-length sort would order # these too that way anyhow (and is relied on to do so in similar code # at tab preselect and popup label fetch ahead, which could be merged); #------------------------------------------------------------------------ drives_sorted = sorted(drives, key=(lambda np: len(np[1])), reverse=True) for storage in [appspec, shared] + [path for (name, path) in drives_sorted]: if storage != None and abspickstart.startswith(storage): topmost = storage if storage != appspec else storage + '/..' break picker.ids.filechooser.rootpath = topmost # same as picker.rootpath # left align paths on open, can be scrolled after def leftalign(dt): picker.ids.pathname.cursor = (0, 0) Clock.schedule_once(leftalign) # Worst Kivy Hack Ever (or so far): nuke list-view "Size" label per above listlayoutobj = picker.ids.filechooserlistlayout try: listsizelabel = listlayoutobj.children[0].children[1].children[1] if listsizelabel.text == 'Size': listsizelabel.text = '' # to test: 'NOPE' except: pass # it's not worth dying for... # [1.1.0]+ (feb24) wrap chooser's os calls in timeouts to avoid nav-tap stalls; # this sets a Kivy hook to an instance of the file-system subclass (see above) picker.ids.filechooser.file_system = fileSystemLocalNoHang #--------------------------------------------------------------------- # add scrollable device/storage buttons to top of picker #--------------------------------------------------------------------- class StorageToggleButton(ToggleButton): """ ================================================================== red button storage-root toggles at top of Main-tab folder chooser: it's possible to mod 'down' color via on_state(), but not worth it; originally just buttons (not toggles) sans scrolling; they've grown; the 'down' preselect uses simple '==' because app.startswith(shared) on android, and everything.startwith(root) on pcs: broadest wins; update: preselect now uses startswith() + decreasing len() sort ahead; reset picker to button's storage root, even if already toggled down; this is complicated, and basically reimplements toogles behavior, but there seems no other way to catch a tab on selected toggles?; the usual on_release callback doesn't get triggerred in this case; on_touch events weirdly bubble through _all_ widgets that register for them: must check touch position for intersection with self, else this appears to always trigger on the same/wrong button (but it's really the first button in .children - which is added last!); note that this would work as a simple func or lambda assigned to togglebtn.on_touch_down (like on_release), but was coded as a class along the way due to a state-skew theory (before found bubbling); most self attrs here would be found in enclosing method's scope, but 'self' of enclosing method would be hidden: use all explicits; this was first inline code, then nested func, then nested class; ================================================================== """ numtouch = 0 def __init__(self, togglelabel, togglepath, picker, devbar, root, navup=False): super().__init__(text=togglelabel) # this button's state self.togglepath = togglepath # used by sort for preselect, etc self.picker = picker # rest used on touch callbacks self.devbar = devbar # picker = popup, devbar = buttons self.root = root # root = self of enclosing method self.navup = navup # used to allow 1 '..' for appspec # stadard toggle button bits self.background_color = 'red' self.group = 'pickstorage' self.allow_no_selection = False # set ToggleButton size, so BoxLayout has size, so ScrollView scrolls # self.padding_x = 144 self.padding_x = kivy.metrics.dp( # scale to density [1.1.0] 72 if not RunningOnAndroid else 55) # see docs at top of .kv self.size_hint = (None, 1) self.bind(texture_size=self.setter('size')) def on_touch_down(self, coords): self.__class__.numtouch += 1 if not self.collide_point(*coords.pos): # it wasn't on self, but we get it anyhow # trace('touchdown %d ignored=>' % self.numtouch, id(self)) return False # keep bubbling, you magnificent bastard else: # it's in this widget's screen area # trace('touchdown %d hit==>' % self.numtouch, id(self)) if self.state == 'normal': # was up: clear all buttons sans self, change picker for child in self.devbar.children: child.state = 'normal' self.state = 'down' self.root.reset_main_picker_path(self.picker, self.togglepath, self.navup) else: # was down: keep self down (only), change picker # oddly, need to reset ~elsewhere first, else no-op elsewhere = osjoin(self.picker.ids.pathname.text, '.') self.root.reset_main_picker_path(self.picker, elsewhere) self.root.reset_main_picker_path(self.picker, self.togglepath, self.navup) return True # end processing: did all toggle behavior manually #--------------------------------------------------------------------- # back to storage-buttons setup #--------------------------------------------------------------------- # or .children[:], .remove_widget() devscr = picker.ids.devicebuttonsscroll devbar = picker.ids.devicebuttons devbar.clear_widgets() # likely superfluous - it's a new popup # shared: really primary on-device (formerly 'SHARED') (never None) lshared = 'PHONE' if RunningOnAndroid else 'PC' bshared = StorageToggleButton(lshared, shared, picker, devbar, self) devbar.add_widget(bshared) #-------------------------------------------------------------------------- # drives: usb + microsd + cd/bdr (and other usb; hubs work on android too!) # should drivename be truncated for button width? no: devbar now scrolled # note the drivepath=drivepath, else same path for all in gui (see lp5e!) # prior point now moot: lambda here replaced with call to external class #-------------------------------------------------------------------------- for (drivename, drivepath) in drives: bdrive = StorageToggleButton(drivename, drivepath, picker, devbar, self) devbar.add_widget(bdrive) # app: optional, never on PCs; after usb, because less used if appspec != None and self.ids.appfolderselections.active: bappspec = StorageToggleButton('APP', appspec, picker, devbar, self, navup=True) # allow 1 up-nav devbar.add_widget(bappspec) # root: optional, weak|None on android, usable on pcs (system drive on Widows) if root != None and self.ids.rootfolderselections.active: # iff Config, show '/' if access else /<other> broot = StorageToggleButton('ROOT', root, picker, devbar, self) devbar.add_widget(broot) # test: to verify storage-button scrolling sans devices teststoragescroll = False if teststoragescroll: for i in range(5): ltest = 'BREAKME' + str(i) btest = StorageToggleButton(ltest, shared, picker, devbar, self) devbar.add_widget(btest) ##bshared.size_hint = (None, 1) #bshared.text_size = devscr.width, None #bshared.size = bshared.texture_size #'120px' #bshared.texture_size[1] ##bshared.bind(texture_size=bshared.setter('size')) # tbd: show documents folder too? - it's a shared sub, and seems too much #-------------------------------------------------------------------------- # preselect: set the button for the storage in which start path is located; # # some storages nest in others (e.g., APP in PHONE on Android, PC in ROOT # on PCs), so match prefix by longest first = most specific/nested; this # works because longer means more specific in hierarchical filesystems; # no button is selected if no match: e.g., in '/' and root disabled on pcs; # # the sort also handles linux 'os' at /media/me and drives at /media/me/xxx # [but see (feb24) note at similar code above: the 'os' mount os C: was bad], # as well as the fact that linux mounts might be anywhere (a mount in home # (PC) will be longer than home itself - though curr only greps in /media # and /mnt: there will be no storage-type root for mounts in /home in the # chooser, but users can manually enter anything (preselect may be anything); # # see also kin: label_path_string() [1.1.0], rootpath nav above [1.1.0] # (some of these could probably have merged or reused code, in retrospect); #--------------------------------------------------------------------------- # sort bar buttons by decreasing path length = longest (most specific) first btnsbypathlen = sorted(devbar.children, key=(lambda c: len(c.togglepath)), reverse=True) for btn in btnsbypathlen: if abspickstart.startswith(btn.togglepath): # absolute path [1.1.0] btn.state = 'down' break # size the bar for its buttons, so it scrolls: => now in .kv """DEFUNCT ------------------------------------------------------------- # output: 0 0, 100 ##trace(devbar.minimum_height, devbar.minimum_width) ##trace(picker.ids.devicebuttonsscroll.width) def setdevbarwidth(dt): # after widgets are drawn: have sizes devscr = picker.ids.devicebuttonsscroll devbar = picker.ids.devicebuttons # output: 0 692, 13920 ##trace(devbar.minimum_height, devbar.minimum_width) ##trace(picker.ids.devicebuttonsscroll.width) devbar.size_hint = (None, None) devbar.height = devscr.height ##devbar.width = max(devbar.minimum_width, devscr.width) devbar.width = devbar.minimum_width ##trace('devbar.width=>', devbar.width) # it doesn't work yet here Clock.schedule_once(setdevbarwidth) # more bind()s might help too ##picker.do_layout() ##devscr.bind(width=lambda i, v: setdevwidth(0)) ------------------------------------------------------------- DEFUNCT""" #------------------------------------------------------------ # update: binds work better, though kivy scrolling is far too subtle; # max() in defunct above triggered scroll long before buttons clipped; # devbar.height = devscr.height <= needed iff size_hint=(None, None); # # THE KICKER: the next two lines can be done entirely in the .kv, and # even before any buttons are added to the BoxLayout (though buttons' # size still must be arranged as they are built above); .kv is easier # way to setup bindings that will fire later; alas, Kivy is so tersely # documented, that you have to do things wrong a few times first... # # now in the .kv file ##devbar.size_hint = (None, 1) ##devbar.bind(minimum_width = devbar.setter('width')) #------------------------------------------------------------ #--------------------------------------------------------------------- # post that dialog #--------------------------------------------------------------------- self._popups.push(Popup(title='Choose %s Folder' % kind, content=picker, auto_dismiss=False, # ignore taps outside size_hint=(0.9, 0.9))) # need max space self._popups.open() #self._popups.top().border = [0, 0, 0, 0] # works iff background image # this can't be set in the .kv spec: see the note there # and can't be done till after return to kivy loop (omg) def do_this_later(dt): picker.ids.filechooser.view_mode = initstyle() Clock.schedule_once(do_this_later) # asap after open def try_any_nohang(self, func, *args, waitsecs=0.25, onfail=None, log=True): # [1.1.0]+ (feb24) """ -------------------------------------------------------------------- [1.1.0] Run any system call with a timeout to avoid GUI hangs. Returns onfail for exception or timeout, else func(*args)'s result. For portability, runs func(*args) in a thread with a timed join. Also wraps the call in a try handler to catch any/all exceptions. Initially coded for os.listdir() only, then updated for any func. UPDATE: os.isdir() hangs on Windows (but not macOS) too: generalize this to call any folder-access function with a timeout. This now returns onfail if func(*args) either raised an exc before timeout or hung; else returns func result if it ended without exc or hang. Still not universal (no **kargs), but handles this app's use cases. Windows drive enumeration now also uses this, not a custom version. Timeouts can also be had with signals on Unix, but not on Windows, and they are also available in multiprocessing, but not on Android. Threads always finish even if timeout: no simple way to kill, but a shared result object per thread ensures that new results aren't overwritten. Hung thread is left to finish (exit on target return) and modify a result object that is ignored by the calling thread. But set daemon=True so thread auto-killed at app exit if hung that long (unlikely); else inherits False from app process and may delay app exit (which _may_ have triggered a kivy logger loop on exit). INITIAL: run an os.listdir() to verify folder access before ops that rely on it, but run it in a thread with a timeout to avoid lags/hangs for drives that are inaccessible - temporarily or permanently. This includes mounted but disconnected network drives, but also others (e.g., USB drives not yet fully mounted). Returns False iff os.listdir() either failed immediately or hung. Used on all platforms and all contexts - chooser start folder, chooser storage tap/reset, and Main action folder prechecks. Also used for an Android hang in storage_path_app_specific(). [1.1.0]+ (feb24) UPDATE: change waitsecs default timer duration from 0.50 to 0.25 seconds; half a second is a bit jarring when a Windows mapped network drive is offline (as they regularly are), and a quarter of a second should be plenty to notice a ghost. Also added logs=True param to allow callers to turn off traces (traces can be too much, but never happen in released Android apps). -------------------------------------------------------------------- """ def try_any(func, args, result, onfail): try: got = func(*args) # run folder-access call: hangware except: result.append(onfail) # failure, immediate, or later if hung else: result.append(got) # success, now or later; got may = onfail result = [] # new shared object for each thread nohang = threading.Thread( target=try_any, args=(func, args, result, onfail), daemon=True) nohang.start() nohang.join(timeout=waitsecs) # wait till thread finished or timeout if nohang.is_alive(): if log: trace('try thread timeout') sendback = onfail # let thread finish and set its ignored result else: if log: trace('try thread exit') sendback = result[0] # thead finished and set result: ok or exc return sendback # can be onfail for exc, timeout, or got #---------------------------------------------------------------------------- # No-hang helpers # # try_listdir_nohang() => True IFF listable, empty or not: no list returned; # use a try_any_nohang() instead if list is needed too (see examples ahead); # try_isdir_nohang() => False if exc, timeout, or false (callers don't care); #---------------------------------------------------------------------------- def try_listdir_nohang(self, path): return self.try_any_nohang(os.listdir, path, onfail=None) != None def try_isdir_nohang(self, path): return self.try_any_nohang(os.path.isdir, path, onfail=False) # Other try_any() clients: Windows drive enumeration, Android drive name, file-system def reset_main_picker_path(self, pickdialog, pickpath, navup=False): """ -------------------------------------------------------------------- On device/storage name tap in Main folder chooser: reset its path + rootpath. Changing the arg-based property changes the chooser's content (magically). (Update: usually; toggle-down clicks resets have to change ~ elsewhere first.) If navup, rootpath includes '..' after path, to allow nav 1 level up (only). Run os.listdir here to make sure it's still there - USB may be removed, though USB won't be in the devices list if user skipped All files access. Storage permission is checked both before we get here for the folder chooser, and before user confirmation for Main-tab actions. Folder access is checked both here, and just after Main-tab action perm check. ---- UPDATE: storage-type-in-list-but-inaccessible is an error state common on newer Androids for the first run (or later, if A-F-A perm is granted later). This now auto-Cancels the chooser dialog on info-message Okay in this state. Else, the dialog remains open with the failed button down; the display is that of a prior type; and it's unclear which button to auto-select for calls. Users must restart the app as instructed if permission is pending, though the dialog could remain for mount pending with prior button still set if recode callers to check listdir() _before_ this is called or the fail button is set. Leaving the dialog open may be better for pending mounts, but seems worse for pending permissions that require a full app restart - an immediate Back no longer kills the app if popups are open, so there are two steps to close. Closing the dialog is a compromise, with a trivial reopen for pending mounts. ---- NIT: chooser open has been seen to hang briefly (2~7 seconds) while a USB drive finishes mounting on some devices - notably, a Fold4 which had just been granted A-F-A without a restart, and a Note20U in the same state. This might trigger ANR, but has not yet, and it's unknown where the hang occurs (may be here or in the chooser's code). On the Fold4, later pending-mount opens do not hang, and do not return the pending drive in the removables list till it's fully mounted; the next note docs Note20U's odd behavior. PUNT - rare, one time, harmless, very complex to address (thread?). ---- ALSO NIT: a Note20U (Android 10) adds a drive in pending-mount state to the chooser list with inaccessible path '/dev/null' till mounted; and, worse, leaves it in the list after drive unmount as inaccessible (to listdir)... UNTIL the user physically removes the drive's cable from the phone. Both cases yield error popups in this app, and now dialog auto-Cancels. By contrast, a Fold4 (Android 13) correctly does NOT add drives to the list till they are fully mounted; and correctly does NOT leave them in list after they are unmounted - regardless of the cable's status. (The dialog refetches the list on each dialog open, so all new states may require a new dialog.) This is too convoluted to address - and likely bugs in older Androids or Samsung add-ons. The only known fix for Note20U oddness is to catch USB mount/unmount events, and this would be required only for the now-fading Android 10 and earlier (presumably) user base. PUNT; users will have to use some common sense when the now-augmented error dialog here appears... ---- UPDATE [1.1.0] hangs for not-yet-mounted drives on Android or elsewhere will no longer happen here, because the listing call is run with a timeout. This (plus similar timeouts elsewhere) addresses the fold4 case NIT above, as well as network drives that have become inaccessible since dialog open. UPDATE [1.1.0]: the Android run1 hang was eventually isolated, and resolved with a timeout in storage_path_app_specific(). UPDATE [1.1.0]+ on macOS only, due to the elimination of hangs, listdir (et al) now returns immediately if the system permission-verify popup is opened (this formerly paused/hung until the popup was closed). Tweak the message to clarify that an app restart is not needed (hopefully). Also, qualify the opening as "on Android" because it won't apply anywhere else: Windows+Linux have no such popups, macOS popups differ. The opener might be omitted on PCs, but its second sentence applies. For 1.1.0, Android+Windows_Winux don't need to care: it's moot or not. This model is also used at verify_paths() and init_logfile_folder(). -------------------------------------------------------------------- """ """ try: os.listdir(pickpath) except: # nested modal dialog else: # pickdialog.path bound to pickstart via creation arg """ # same, but use timeouts to avoids lags for inaccessible drives [1.1.0] listed = self.try_listdir_nohang(pickpath) if not listed: # hung (inaccessible), or immediate fail (unmounted?) # nested modal dialog noaccessmsg = ( 'Cannot access %s.' # [1.1.0] add dots to all line1 '\n\n' 'If you just granted storage permission on Android, ' # [1.1.0]+ 'please try restarting this app now.' '\n\n' 'Otherwise, retry after ensuring that drives ' 'have been fully mounted and recognized.' ) if RunningOnMacOS: noaccessmsg += ( '\n\n' 'On macOS, you may be able to simply rerun the last ' 'request if you just handled the permission popup.' # [1.1.0]+ ) if RunningOnAndroid and self.android_version() <= 10: noaccessmsg += ( '\n\n' 'On older Androids, you may need to wait for ' 'mounts to finish, or detach after unmounts.' ) if not RunningOnAndroid: # [1.1.0] added noaccessmsg += ( '\n\n' 'On PCs, also check folder permissions and locks, ' 'and network-drive status.' ) closechooser = lambda: self.dismiss_popup(pickdialog) self.info_message(noaccessmsg % pickpath, usetoast=False, nextop=closechooser) # close chooser too else: # storage root accessible now # pickdialog.path bound to pickstart via creation arg up = '/..' if navup else '' # android app-specific only pickdialog.pickstart = pickpath # reset chooser's content (ids.fc.path) pickdialog.rootpath = pickpath+up # remove '..', avoid excs for bad dirs # left align on reset, can be scrolled after def leftalign(dt): pickdialog.ids.pathname.cursor = (0, 0) Clock.schedule_once(leftalign) def do_main_path_pick(self, kindid, popuppath, popupcontent): """ On Pick, copy chosen path to Main tab, and close the picker dialog (which shows the same path next time). Don't save from/to paths until a Main-tab action is run (else may be a bogus path, and would need to catch any manual changes too). No need to clear selection: TextInputNoSelection disables it. (Formerly: clear_selection() here doesn't fix lingering selection handles, and prior selection auto disappears with new popup.) """ # now passed in from .kv file # popuppath = self._popups.top().ids.pathname.text # brittle, that mainpath = self.ids[kindid] # from|to, Main tab mainpath.text = popuppath # from or to path self.dismiss_popup(popupcontent) def dismiss_popup(self, popupcontent): """ Used for all modal popups; this coding avoids setting popup content's close handler after the popup is made (popup needs content, but content close needs popup...). Now just a convenience interface to the popups stack. Kivy Popups also have an auto_dismiss, which runs dismiss() on taps outside the dialog, and is enabled by default. UPDATE: pass Popup's content to avoid closing an overlaid dialog on double taps (see popups stack for more info). Also set Kivy auto_dismiss=False to prevent taps and closes outside scope of popups stack (plus ambiguous+twitchy). """ self._popups.dismiss(popupcontent) # topmost: no longer assumes only 1 modal """ UNUSED def filter_dirs_by_permission(self, itemslist): "" In Main filechooser, rule out folders that can't be accessed. These don't open in the GUI, but trigger uncaught exceptions. UNUSED: only folders accessed by ".." generate uncaught (harmless) exceptions: avoid by setting chooser's rootpath. "" def accessible(folder): try: os.listdir(folder) # caution: may hang except: return False return True return [item for item in itemslist if not os.path.isdir(item) or accessible(item)] """ #======================================================================= # MAIN-TAB ACTION - OPTIONS (DOC) #======================================================================= """ This program runs tasks that are long running, and potentially _very_ long running. These tasks cannot block the GUI, and so must be run in parallel with it. To do so, there are a number of options whose tradeoffs vary per plaform: 1) Simple child processes created by subprocess.Popen() Continue to run if the app or program is left for another, (which pauses the app on Android), and are a portable tool. This is an option on PCs, but not Android. On Android, child processes may be arbitrarily terminated by the Android 12+ 'phantom' process killer, which makes this a nonstarter. On PCs, there is neither pause equivalent nor processes killer, and this is a standard option. This is what was used for scripts and GUIs on Android in the Termux and Pydroid 3 apps. Neither ever saw a process kill, but this varies by phone usage patterns. This mode requires an IPC scheme to signal script-process exit to the GUI process, unless the GUI is polling for EOF on the logfile read by a threas (see Mergeall's GUI launcher) or the appearance of a sentinel file (see Frigcal's launcher). The tkinter GUI did EOF polling with unbuffered output streams + a consumer thread's blocking-read loop + a timer-polled thread queue. This costs cpu and power on phones, but a similar scheme can be used here on PCs to skip IPC altogether. TBD: the next option may work just as well on PCs. YES: thread scheme coded for Android adopted for all PCs too. 2) Threads spawned by the GUI process Continue to run if the program or app is left/paused; are fully portable to PCs and Android; are immune to Android process kills; and require no IPC mechanism to signal their exit (an in-process callback can be run directly in the thread-wrapper's exit code). This is available on Android as an option, and was the first coding. It's only downside seems to be that script exit cannot be reported to users until the app is resumed. Threads may incur a minor speed hit for thread-switching performed within the Python virtual machine (see its global interpreter lock). However, this is likely to be minor, and negligable in multi-minute runs. Per benchmarks, there is _no_ noticable speed difference for the same tasks run as process in Termux and thread in the app (and later, service in the app). Indeed, Android pseudo-nondeterminism makes processes as likely to lose the race. 3) Android-only Foreground services, which run scripts in auto processes Continue to run if the app is left, and are unlikely to be killed by Android, but are fully proprietary and nonportable, and require a simlarly proprietary IPC mechanism to catch exits while paused. This is used by default on Android, for its notifications. The only advantage over threads is that notifications are posted for tasks, which sufficces to report script status and exit to the user while the app is paused. The app's toast is posted on script exit but doesn't appear except in the app; global overlays are possible, but intrusive (and possibly rude). Service processes may require an IPC mechanism for exits, because the script runs in a separate process (callbacks are out); EOF or sentinel-file polling in the GUI may not detect a script exit while the app is left/paused; and EOF polling requires setting stdout to be unbuffered for the script's run. Unix IPC and pipes are unusable, because the process is auto-spawned by Android. As coded, script exit is coded as a proprietary BroadcastReceiver. In hindsight, given that toasts don't overlay other apps and can't appear intil the app is revisited, signal-file polling may suffice. Complication: foreground services keep running if app killed (as supported in kivy/p4a today), so must check if still running on app restart, and reset in-progress state, as well as any postscr callback. This is handled with a service breadcrumb file as coded here. The alternative is killing the service with the app, and seems as hard. UPDATE: punt - Back-button app kills now prohibited if any Main action is in progress, as service-running detection is inaccurate and iffy. Android services can also run a thread, which may avoid IPC, but also requires from-scratch service coding, and has no clear benefit. 4) The multiprocessing module's processes Cannot be used on Android, due to a missing semaphore call, and seems overkill and seems likely to be slower in any event. """ #======================================================================= # MAIN-TAB ACTION: PROCESS #======================================================================= def launch_script_process(self, opname, script, cmdargs, logfilepath, stdin=None, postop=None): """ -------------------------------------------------------------------- On PCs, launch the script as a simple subprocess.Popen() child process, with sentinel-file or EOF polling to detect script exit. DEV: it's not clear that this would be any better than using the portable thread scheme developed for Android intially... UPDATE: now using threads on all PCs. This has not discernable performance negative, but the future remains to be written... -------------------------------------------------------------------- """ return # TBD (sort of) """ # catch app close (prints okay: new process) self.script_running = PROCESS_RUNNING self.on_script_start(opname) # label, gif, buttons self.on_script_exit(opname, logfilepath, logfile=None) # updates gui """ #======================================================================= # MAIN-TAB ACTION: SERVICE+PROCESS #======================================================================= def launch_script_service(self, opname, script, cmdargs, logfilepath, stdin=None, postop=None): """ -------------------------------------------------------------------- Run the Main -tab action's script as an Android ForegroundService, in an automatically spawned process (but not 'sticky': restarts). This allows the action to keep running when users leave the app (like threads), and reports script status and exit with a normal notification while paused (unlike threads). Much of the java interface is automated by p4a (unlike FileProvider!), including both a java service class and pyjnius broadcast-receiver code for process-exit dispatch, though there are glaring seams (see the hurdles list in the service runner). This scheme also requires: 1) buildozer.spec service=arg setting (below), which maps service name to python file, and triggers creation of java service class. Also: +FOREGROUND_SERVICE in permission settings (crucial, undoc!). 2) run_script_as_service.py, which has the main+wrapper code run in the service process that unpacks args and runs the target script's code. This file is auto run as a script on service start below. 3) Broadcast send in the closing code of #2, using an Intent and the same message ID used to create the BroadcastReceiver here. Docs (such as they are): https://python-for-android.readthedocs.io/en/latest/services/ (ditto)/en/latest/apis/?highlight=broadcast#module-android.broadcast https://github.com/kivy/python-for-android/... (for code spelunking) Generated java service class(ES): ~/.../PC-Phone-USB-Sync/.buildozer/android/platform/ build-arm64-v8a_armeabi-v7a/dists/usbsync(TRIAL?)/ src/main/java/com/quixotely/usbsync(TRIAL?)/ServiceRunscript.java Min: service.start(mActivity, argument) Max: service.start(mActivity, 'small_icon', 'title', 'content' , argument) Empty strings on three args pick reasonable defaults in generated Java code -------------------------------------------------------------------- """ # java dir varies by app: here (start + msgid), service wrapper (exit msg) appsuffix = 'usbsync' if not TRIAL_VERSION else 'usbsynctrial' # config gui and msg receiver in this process msgid = self.service_in_progress_state(opname, logfilepath, postop, appsuffix) # pass args as json string argsdict = dict(mainfilepath=script, argslist=cmdargs, stdouterr=logfilepath, onexitid=msgid, appname=appsuffix, stdin=stdin) argumentstr = json.dumps(argsdict) trace('json=>', argumentstr) # start service+process (via generated java class: dirname differs per app!) service = autoclass('com.quixotely.%s.ServiceRunscript' % appsuffix) mActivity = autoclass(ACTIVITY_CLASS_NAME).mActivity service.start(mActivity, '', APPNAME_FULL_OR_TRIAL, 'Running %s action' % opname , argumentstr) # and in buildozer.spec (not + ':sticky' = restart): # services = runscript:run_script_as_service.py:foreground # save breadcrumb file for app restarts while service still running # no longer used: worked, but service-running test is deprecated # and error prone; see check_for_running_service() for more docs """ svcrecord = open(SERVICE_BREADCRUMB, 'wb') pickle.dump([opname, cmdargs], svcrecord) svcrecord.close() """ def service_in_progress_state(self, opname, logfilepath, postop, appname): """ -------------------------------------------------------------------- A method, because used both at service launch, and by app start-up code if service in progress post app kill+restart. We either need to kill a service on app exit (kivy/p4a don't), or detect and reset in-progress state to avoid allowing a new action run. Threads don't need to care: they die with the Android app. On restarts: opname unknown, and postop is MIA. Nit: should msgid vary for full/trail apps? This may or may not matter, users seem very unlikely to run both at once, and maybe we don't need to care. App-name diffs are _forced_ for both the foreground service itself (by the names of generated java-code folders), and OPEN's file providers (by installer which checks that provider name is not already used by an installed app). UPDATE: appname (suffix) now used in broadcast message id here too. It's unknown if this is required for message ids, but was adopted here after installer mandated it for file providers... -------------------------------------------------------------------- """ # catch app close (prints okay: new process) self.script_running = SERVICE_RUNNING self.on_script_start(opname) # label, gif, buttons # start script-exit broadcast receiver msgid = 'com.quixotely.%s.SCRIPT_EXIT' % appname def onexit(context, intent): # always 2 args! self.on_service_process_exit(opname, logfilepath, postop) self.breceiver = BroadcastReceiver( callback=onexit, # run in this process, not service actions=[msgid]) # intent action name, arbitrary self.breceiver.start() return msgid def on_service_process_exit(self, opname, logfilepath, postop): """ -------------------------------------------------------------------- On receipt of the script-exit broadcast message from service/process in the main app/gui process. Same as for thread, but run postop in main app/gui process here and now (the thread variant runs it in the thread runner itself), and the service process closes the logfile. This will be triggered whether the app is foreground or paused, though the toast it generates doens't appear unless and until the app is in the foreground (visible). There are ways to force app overlays, but this is too intrusive. The service does, however, post a notification while running. This notification usually shows up after ~8 seconds on first run (and faster thereafter); is not noticable for very short runs; and is auto-removed on service exit. It also qualifies as useful and ample user notice, and warrants using a service. This callback apparently has 5 seconds to finish; bizarro! UPDATE [1.2.0] there is a rumor (well, just 2 unrecreatable aborts among a gazillion runs) that pyjnius code somewhere in breceiver.stop() is prone to raise an uncaught exception that closes the app. Try to address here, but this may be due to holding Java objects too long (or not long enough), and is too low in the libs to be sure (it's a hard crash, with Java stack details that mean zit here). Such is life with a glitchy stack... -------------------------------------------------------------------- """ if postop: postop() self.on_script_exit(opname, logfilepath, logfile=None) # updates gui try: self.breceiver.stop() except: pass # [1.2.0] maybe recover? trace('exiting broadcast receipt callback') #======================================================================= # MAIN-TAB ACTION: THREAD #======================================================================= def launch_script_thread(self, opname, script, cmdargs, logfilepath, logfile, stdin=None, postop=None, timersecs=0.10): """ -------------------------------------------------------------------- Create a cross-thread object queue, start the script thread, and start a timer-event queue consumer in the main GUI thread. All stdout and stderr in script thread goes to Pipe's queue. Unicode: logs always UTF-8, GUI always run through ascii(). Threads keep running when the app is left/paused, but script exit cannot be reported to the user until the app is resumed on Android (unlike services, they do not post a notifcation). Threads are used on Android as an option (services are default, except on Android 8), and also on macOS, Windows, and Linux PCs. ---- UPDATE: scrolled run output proved too slow, and was replaced with an animated GIF plus on-demand TAIL/WATCH on Logs tab. For this new mode, STATUS_ANIMATION, there is no need for either a timer loop or queue. Instead, use a simple stream flusher, and pass a callback object to be invoked when the thread runner closes the streams flusher after the script's code finishes. This works because it's all one process: the callback is in-process, and the script runner in the thread knows when the script exits. The callback uses kivy's @mainthread to ensure GUI-thread execution for GUI upates triggered by the callback. There is also no need for running display text through ascii(): kivy displays unknown glyphs as rectangles, which is as good/bad. -------------------------------------------------------------------- """ # catch app close, disallow prints self.script_running = THREAD_RUNNING self.on_script_start(opname) # mod label, gif, buttons if STATUS_SCROLL: self.text_size = 0 # reset output dislay/size self.text_line = 0 # num lines for scrolling self.pending_lines = '' # initialize lines batch self.pending_count = 0 self.run_output.text = '' # clear text area in gui if STATUS_ANIMATION: pipe = Flusher(logfile, on_eof=lambda: self.on_script_exit(opname, logfilepath, logfile)) if STATUS_SCROLL: pipe = Queuer() thread = run_script_thread_wrapper( # spawn the script thread script, # path to script file cmdargs, # sys.argv[1:] for script pipe, # logfile proxy + exit signal postop, # postop run by wrapper stdin, # canned input iff used chdir=True, # goto script's folder trace=False) # trace=True for trace2() if STATUS_ANIMATION: pass # no timer loop needed if STATUS_SCROLL: def callback(dt): self.scroll_on_action_timer_callback(pipe, logfile) self.timer_callback = callback # keep a ref, ignore dt Clock.schedule_interval(callback, timersecs) # auto-rescheculed timer @mainthread def on_script_start(self, opname): """ Common script-start GUI code: thread, service, process. Assumes animation coding; former scroll scheme won't work. [1.1.0] Update: set partial wakelock for cpu if screen off. """ self.ids.statuslabel.text = opname # action label self.ids.statusimg.anim_delay = 0.20 # animate gif: 5x/sec # disable action buttons for actionbutton in self.action_buttons: self.ids[actionbutton].disabled = True # acquire partial wake lock on action start [1.1.0] if RunningOnAndroid and not self.wakelock.isHeld(): trace('acquiring wakelock') self.wakelock.acquire() @mainthread def on_script_exit(self, opname, logfilepath, logfile=None): """ -------------------------------------------------------------------- When action exits in animation coding, reenable app closes and prints, stop animation, close logfile, reenable Main buttons. This is now used by thread and service+process/broadcast modes. It's likely thread and broadcast safe: scheduled by @mainthread. Update: also clear arrowheads with .reload() - marginally neater. Nit: could re-deable NAME on Windows here, but punting on this. [1.1.0] Also scrape logifile summary info, and release Android partial wake lock (supports screen timeouts: see App.on_start()). -------------------------------------------------------------------- """ # reenable app close, prints (thread or not), stop WATCH self.script_running = NOTHING_RUNNING self.ids.statuslabel.text = 'Action Status' # back to generic label self.ids.statusimg.anim_delay = -1 # stop Main-tab animation self.ids.statusimg.reload() # reset: clear arrows if logfile: logfile.close() # if thread, not service+process # reenable action buttons (maybe undo) for actionbutton in self.action_buttons: self.ids[actionbutton].disabled = False if self.undo_verboten: self.ids.undobutton.disabled = True # and Windows NAME? - punt # release partial wake lock on action exit [1.1.0] if RunningOnAndroid and self.wakelock.isHeld(): trace('releasing wakelock') self.wakelock.release() # try to scrape and display crucial info from logfile [1.1.0] summary = self.scrape_action_summary_info(opname, logfilepath) self.info_message('%s action finished.%s' % (opname, summary), usetoast=(not ('\n' in summary))) def scrape_action_summary_info(self, opname, logfilepath, tailsize=(10 * 1024)): """ -------------------------------------------------------------------- [1.1.0]: For convenience, try to parse out and present the crucial logfile info in the exit popup. It's arguably hard to view logs on a narrow phone sans landscape rotate, and summmary bits extracted here normaly suffice, especially when verifying counts against syncs on PCs. This must avoid hangs/lags by reading just the end of file (like TAIL and WATCH), and the parse can easily fail and must recover (logs can end with errors sans summaries). Also changed to propagate logfilepath from spawners (service +thread); it originates in Main's confirmed(). -------------------------------------------------------------------- """ def commify(obj): # add commas to str|int number for display if isinstance(obj, str): obj = int(obj) # str=>int return '{:,}'.format(obj) # always returns str try: # read tail only: don't hang gui logsize = os.path.getsize(logfilepath) logfile = open(logfilepath, 'r', encoding='utf8', errors='replace') logfile.seek(max(0, logsize - tailsize)) logtext = logfile.read() logfile.close() if opname in ['SYNC', 'UNDO']: scrape = ( r'^Compared => files: (\d+), folders: (\d+), symlinks: (\d+)\n' r'^Differences => .*?\n' r'^Changed:\n' r'^files => created: (\d+), deleted: (\d+), replaced: (\d+)\n' r'^folders => created: (\d+), deleted: (\d+), replaced: (\d+)\n' ) matched = re.search(scrape, logtext, re.MULTILINE) if matched: groups = matched.groups() summary = ( 'Items compared:\n' ' Files: %s\n' ' Folders: %s\n' ' Symlinks: %s\n\n' ) totals = [int(text) for text in groups] nummods = sum(totals[3:]) if nummods == 0: groups = groups[:3] summary += 'No changes were made.' else: summary += 'File changes: %s\n' % commify(sum(totals[3:6])) summary += ( ' Created: %s\n' ' Deleted: %s\n' ' Replaced: %s\n\n') summary += 'Folder changes: %s\n' % commify(sum(totals[6:9])) summary += ( ' Created: %s\n' ' Deleted: %s\n' ' Replaced: %s') elif opname == 'SHOW': scrape = ( r'^Compared => files: (\d+), folders: (\d+), symlinks: (\d+)\n' r'^Differences => ' r'samefile: (\d+), uniqueto: (\d+), uniquefrom: (\d+), mixedmode: (\d+)' ) matched = re.search(scrape, logtext, re.MULTILINE) if matched: groups = matched.groups() summary = ( 'Items compared:\n' ' Files: %s\n' ' Folders: %s\n' ' Symlinks: %s\n\n' ) totals = [int(text) for text in groups] numdiffs = sum(totals[3:]) if numdiffs == 0: groups = groups[:3] summary += 'No differences were found.' else: summary += 'Differences found: %s\n' % commify(numdiffs) summary += ( ' Same file: %s\n' ' Unique in TO: %s\n' ' Unique in FROM: %s\n' ' Mixed kind: %s') elif opname == 'COPY': scrape = ( r'^Copied (\d+) files, (\d+) folders, (\d+) symlinks' ) matched = re.search(scrape, logtext, re.MULTILINE) if matched: groups = matched.groups() totals = [int(text) for text in groups] summary = 'Copied items: %s\n' % commify(sum(totals)) summary += ( ' Files: %s\n' ' Folders: %s\n' ' Symlinks: %s') elif opname == 'DIFF': scrape0 = ( r'^Files checked: (\d+), Folders checked: (\d+), ' r'Symlinks checked: (\d+), Files skipped: (\d+)\n' r'(?:.*\n)?' ) scrape1 = scrape0 + '^No diffs found\.' scrape2 = scrape0 + r'^Diffs found: (\d+)' summary = ( 'Items checked:\n' ' Files: %s\n' ' Folders: %s\n' ' Symlinks: %s\n' ' Skipped: %s\n\n') matched = re.search(scrape1, logtext, re.MULTILINE) if matched: groups = matched.groups() summary += 'No differences were found.' else: matched = re.search(scrape2, logtext, re.MULTILINE) if matched: groups = matched.groups() summary += 'Differences found: %s\n' uniques = logtext.count('\n- items UNIQUE at [') differs = logtext.count('\n- files DIFFER at [') differs += logtext.count('\n- links DIFFER at [') misses = logtext.count('\n- items MISSED at [') summary += ( ' Same file: %s\n' ' Has uniques: %s\n' ' Mixed kind: %s' ) % tuple(commify(tot) for tot in (differs, uniques, misses)) elif opname == 'NAME': scrape0 = r'^Visited (\d+) files and (\d+) folders\n' scrape1 = scrape0 + r'^Total nonportable names found but unchanged: (\d+)' scrape2 = scrape0 + r'^Total nonportable names found and changed: (\d+)' summary = ( 'Items visited:\n' ' Files: %s\n' ' Folders: %s\n\n') matched = re.search(scrape1, logtext, re.MULTILINE) if matched: groups = matched.groups() summary += 'Names found but unchanged: %s.' else: matched = re.search(scrape2, logtext, re.MULTILINE) if matched: groups = matched.groups() summary += 'Names found and changed: %s.' if not matched: summary = '\n\nCould not extract summary.\n\n' else: commags = tuple(commify(text) for text in groups) summary = summary % commags summary = '\n\n' + summary + '\n\n' # spurious for name and diff - today indicator = r'^\*\*There are (error messages|non-error notes)' if re.search(indicator, logtext, re.MULTILINE): summary += '**This run has errors or notes to inspect.\n\n' summary += 'See Logs for more info.' except: trace('scrape exc:', sys.exc_info()) summary = '\n\nCould not extract summary: see Logs.\n\n' pass # bail on all file or parse errors return summary def init_mergeall_globals(self): """ -------------------------------------------------------------------- UPDATE: this is not required for foreground service+process or process run options, as the script's global state is its own. See also update_mergeall_configs_maxbackups() for related mods. A downside of running Mergeall scripts as strings in a thread: any globals in imported modules aren't reinitialized per run, because the script is rerun in the same process. For example, this causes problems in backup.py, because it uses globals to trigger creation of a backup-name timestamp once per run; if not reset, the same backups folder is created for each mergeall.py run in the app; ouch. The options. 1) Going back to subprocesses instead of threads fixes the global issue, but makes app actions subject to the phantom-process killer for child processes on Android 12+; this is a non-starter. 2) Importing modules with global state and resetting globals before mergeall.py runs (something similar is already done for changing mergell_configs.MAX_BACKUPS changes). 3) Removing the offending modules from sys.modules to force reimports on each run. Of these, 2 seems least risky, given the many changes to imports in python 3.Xs. Note that this pertains only to loaded modules; the top-level script is recompiled from a string each time, and hence starts from scratch. This is also required for mergeall.py only: on SYNC (for backups and copies), and UNDO (for copies), but not on SHOW (which does neither). NIT: this could avoid imports if modules are already in sys.modules, but the import is required on first thread run, and trivial after. NOTE: none of the attibutes reset here show up as function argument defaults, which could make the resets useless - see maxbackups... Running as a thread with exec also makes output tricky (all prints go to the script's output pipe), and requires that the cwd is moot. OTOH, random child-process kills are a Very Bad Thing in syncs... -------------------------------------------------------------------- """ # ./mergeall/ modules are loaded after a run - in just one form # for mod in sys.modules.values(): # trace('$>', mod) # __name__, __file__ # no-op if not running a thread: process imports anew # TBD: also no-op for simple process, iff used on pcs # as coded, this assumes either service else thread, # probably better to call from launch_script_thread() if RunningOnAndroid and self.ids.runactionasservice.active: return else: # for thread runs of mergeall.py only: android or pc trace('ma=> globals reset: backup, cpall') os.chdir('mergeall') # in app-install (unzip) folder sys.path.append(os.getcwd()) # .pyc not text, '.' fails here import backup, cpall # identical to mergeall.py runs backup.datetimestamp = None # run in subfolder, same process backup.pruned = False # reset globals: as if new process backup.firstbkpmsg = True cpall.anyErrorsReported = False os.chdir('..') # back to app's cwd, and import path sys.path.pop() # NO LONGER USED """UNUSED def scroll_on_action_timer_callback(self, pipe, logfile, linesbatch=8): "" On timer event: consume next batch of output lines queued by action thread, if any, and route each line to the line handler. Returning False here means don't reschedule callback in Kivy. This structure avoids blocking main GUI thread while script runs. "" try: line = pipe.linequeue.get(block=False) except queue.Empty: return True # reschedule timer loop # process a batch of lines batchnum = 0 while True: if line == pipe.eofsignal: self.scroll_on_output_line(None, logfile) return False # end of consuming loop else: batchnum += 1 self.scroll_on_output_line(line, logfile) # add to log and gui if batchnum == linesbatch: break # back to top of timer loop elif pipe.linequeue.empty(): break # back to top of timer loop else: line = pipe.linequeue.get(block=False) # back to top of batch loop return True # reschedule next callback UNUSED""" # NO LONGER USED """UNUSED def scroll_on_output_line(self, line, logfile, guimaxchars=(256 * 1024), guibatch=100): "" Handle next output line from action thread. Run from on_action_timer_callback() for lines in queue. This avoids overflflow in the text widget by halving text (TBD). This avoids Unicode issues in the GUI with ASCII mapping (TBD). UPDATE: despite tweaking timer and queue batch settings and halving of text size, scrolling line by line was too slow on phones - thread runtimes > doubled, and runs occasionally hung or terminated. Tried changing to send text to GUI only in guibatch chunks here, This helped with speed, but a few random crashes were still seen. PUNT: on realtime scrolling, and use an animated GIF for run status, along with tail/watch on-demand options in the Logs tab. NIT: the logfile write() here should really catch Unicode errors and use ascii() like the Flusher class does, but we're punting anyhow... "" if line == None: # eof at thread exit # same as animation endthread (plus image) self.on_script_exit(?, logfile) # has changed... # show final lines batch in gui self.run_output.text += self.pending_lines self.ids.runoutputscroll.scroll_y = 0 self.pending_lines = '' self.pending_count = 0 else: # next output line from thread try: #trace2('>>', repr(line)) logfile.write(line) # <= should catch Unicode errors #logfile.flush() # to tail file (slows slightly) except: self.info_message('Cannot write line to logfile.', usetoast=True) line = ascii(line[:-1]) # avoid Unicode probs in GUI, keep \n line = line[1:-1] # strip quotes added by ascii() line = line + '\n' # lines always have a \n at end linelen = len(line) # len after any ascii() expansion if self.text_size + linelen >= guimaxchars: # too big: chop in half trace2('Halving text', self.text_size) fulltext = self.run_output.text # else text widget hangs fulllen = len(fulltext) halflen = fulllen // 2 halftext = fulltext[halflen:] self.run_output.text = halftext self.text_size = fulllen - halflen self.text_line = halftext.count('\n') self.text_size += linelen self.text_line += 1 self.pending_lines += line self.pending_count += 1 if self.pending_count == guibatch: # show batch of text in gui now self.run_output.text += self.pending_lines # insert_text(s, from_undo=False) #self.run_output.cursor = (0, self.text_line) # scroll to line added? - no # textinput.cursor doesn't hscroll (kivy bug): use scrollview wrapping it self.ids.runoutputscroll.scroll_y = 0 self.pending_lines = '' self.pending_count = 0 # gui redrawn auto now or on return to timer loop # alt: force redraws with self.canvas.ask_update()) UNUSED""" #======================================================================= # MAIN-TAB ACTIONS #======================================================================= # All Main-tab action button ids in .kv file action_buttons = [ 'syncbutton', 'showbutton', 'undobutton', 'copybutton', 'diffbutton', 'namebutton' ] def confirm_action(self, message, # text for confirm popup, None=preconfirmed opname, # text of button pressed script, # path to main script file cmdargs, # list of args for script postcon=None, # run iff confirm, before script, in gui thread postscr=None, # run iff confirm, after script, in gui|script thread stdin=None, # string to pipe into thread's stdin popupcontent=None # propagated from callback by preconfirmed callers ): """ -------------------------------------------------------------------- Confirm action with message. Launch script thread/service/process iff confirmed. Also save settings now: configs and from/to paths. Split into steps/continuations to force modal dialogs. Formerly started a GUI timer loop; now just status animation, and wait for thread exit or broadcast message. Simple process mode may start a polling loop for pipe reads; tbd. postcon: called iff confirmed, before script, in the GUI thread. postscr: called iff confirmed, after script, in script thread for thread mode, and in GUI thread in service mode on message receipt. postscr is complicated by fact that app may be restarted while a service is still running. To support app restarts, save opname to a file, and handle UNDO's postscr callback specially on restarts. UPDATE: punt - service-running detection is deprecated and iffy. UPDATE: if message is None, it's assumed that the action has been preconfimed with a still-open popup - skip the normal confirmation dialog, and run the action as if Yes had been pressed. Added for the NAME report|update rewrite ahead; a bit of a kludge, but it's simpler than refactoring confirmed() at this point in the project. UPDATE [1.2.0]: this is now 3 steps instead of 2, to allow for up to 2 modal dialogs before action run: new Android 13+ notification permission infobox + dialog, and/or prior user-confirmation dialog. -------------------------------------------------------------------- """ def confirm_confirmed(popupcontent): """ ---------------------------------------------------------- [confirm_action step #3] On user confirmation of any Main-tab action. Uses multiple variables in the enclosing scope. Storage perms now checked in verify, before this. Notification perm resolved before this, if needed. Pruning + new logfile here will cause the Logs tab to reload its logfile list when switch to Logs tab. [1.2.0] Pruner now filters out new logfile by name passed here, else may prune newly made logfile if host's date/time is old. See make_new_logfile(). ---------------------------------------------------------- """ # close run confirmation self.dismiss_popup(popupcontent) # always log, but auto-clean folder (Logs tab will reload) logfileandpath = self.make_new_logfile(opname) if not logfileandpath: return logfile, logfilepath = logfileandpath # bailed if failed # prune for new log on each run (Logs tab will reload) self.auto_clean_logfiles(logfilepath) # [1.2.0] save impending run's valid logfile path for WATCH self.current_run_logfile_path = logfilepath # save Main paths on each run self.save_persisted_settings_Main() # if-confirmed op in this thread and process, pre-run if postcon: postcon() # allow non-update actions to be killed by Back button? # no: requires detecting others in-progress on restart # self.update_action = (opname not in ('SHOW', 'DIFF')) doservice = self.ids.runactionasservice.active if RunningOnAndroid and doservice: # Android only: foreground service + process (default) # broadcast receiver for script-exit signal logfile.close() self.launch_script_service( opname, script, cmdargs, logfilepath, stdin, postscr) elif RunningOnAndroid or not RunningOnAndroid: # Shakespearean, that # Android and PCs: posix thread in the app's process (original) # run if paused, script-exit via thread wrapper self.launch_script_thread( opname, script, cmdargs, logfilepath, logfile, stdin, postscr) else: # PCs only: child process via subprocess.Popen? (TBD) # sentinel-file polling for script-exit signal self.launch_script_process( opname, script, cmdargs, logfilepath, logfile, stdin, postscr) def confirm_askuser(): """ ---------------------------------------------------------- [confirm_action step #2] [1.2.0] On dismissal (or no-show) of notifications info. Uses multiple variables in the enclosing scope. Appears under system perm dialog on first service action, but this seem better than both convolutinf first open, and pre-andr13 api perm dialog while first action runs. Exception: NAME will run while perm dialog is open; rare. ---------------------------------------------------------- """ if message == None: # preconfirmed, propagate content confirm_confirmed(popupcontent) # uses args in enclosing scope else: confirmer = ConfirmDialog(message=message, onyes=confirm_confirmed, onno=self.dismiss_popup) self._popups.push(Popup(title='Confirm Action Run', content=confirmer, auto_dismiss=False, # ignore taps outside size_hint=(0.9, 0.8))) # wide for phones self._popups.open() #---------------------------------------------------------- # [confirm_action, step #1] # # On all Main-tab actions, post checks and preps. #---------------------------------------------------------- # [1.2.0] verify or ask first, on android 13+ for services self.check_notification_permission(nextop=confirm_askuser) def check_notification_permission(self, nextop): """ -------------------------------------------------------------------- [1,2,0] Android 13 (API level 33) mods notifications permission: it must be called out in the manifest (via buildozer.spec), and apps themselves are now responsible for asking the user to enable the permission with code below (it used to be fully automatic, and asked once at first foreground-service start). Because Play requires API level <= 1 year old for app changes, supporting this was required to release a new version with fixes. Yes, grrr. This is _almost_ automated by p4a's permissions lib, but it should really ask just once, and bail if the user said no (or yes) in the past; unlike storage permission, this is not a core req, and applies iff an action is run as a foreground service. Also: the Android request call simply posts an old-style runtime permission dialog, instead of opening Settings' notification toggles page. This is far less custom than All Files Access. Ref: https://stackoverflow.com/questions/72310162/ how-do-i-request-push-notification-permissions-for-android-13 ---- Coding/timing dilemma: 1) If this is posted on first service run, the infobox will be overlaid by the end-run summary for a fast action, and the system dialog doesn't appear till the infobox is dismissed (a four-level interaction: confirm, summary, infobox, dialog; yikes). The perm also won't apply to the first action run (as before). 2) If this is posted at app first open, it won't be lost under first action run, but there's no reason to ask unless and until an action is run as a service (users may select threads instead, especially if future Androids deprecate fgservice for bkp/sync). #1 is basically how this auto worked before Andr13, except that the infobox keeps the system dialog from appearing while covered. It was confusing before, is probably more confusing with the new infobox, and means no notification for first action. Should either: A- Ask at first open always (like/after a-f-a) B- Drop the infobox to emulate pre-13 behavior C- Make infobox modal with another continuation, like confirm dialog #A is coherent, but overkill if just threads, and convolutes startup UI #B is still confusing when a first action finishes before dialog closed #C is best but complicates code: requires another continuation in confirm Either way, should ask on first _service_ run (not threads) and only if haven't already asked (granted or not). #A doesn't have to check if already asked (and may be able to skip checking if granted with the bizarre+iffy android api method, though unclear for upgrades), but it requests the permission pointlessly if user will run threads. Went with #C. Made infobox here a first modal step of confirm, so that system perm dialog overlays action confirm popup, and must be handled+dismissed before action starts to run. This makes the permission active for the first action too, and defers action run until perm is handled. Caveat: NAME still runs before perm dialog is handled, but it's better than pre-13 (the infobox here gets focus first); the odds of NAME running first are very slim; and this is just a one-time event, after all (users probably won't care). -------------------------------------------------------------------- """ # not for pcs or threads doservice = self.ids.runactionasservice.active if not (RunningOnAndroid and doservice): trace('np=> not relevant platform or mode') nextop(); return # not for androids 8..12 if self.android_version() < 13: # a.k.a. API level 33 trace('np=> not relevant android') nextop(); return # work the way it used # monkey patch: kivy android.permissions grew this later if not hasattr(Permission, 'POST_NOTIFICATIONS'): Permission.POST_NOTIFICATIONS = "android.permission.POST_NOTIFICATIONS" # already got one? if check_permission(Permission.POST_NOTIFICATIONS): trace('np=> Has post_notification permission') nextop(); return # welcome to trial-and-error theater #actcompat = autoclass('android.ActivityCompat') #Manifest = autoclass('android.Manifest') activity = self.get_android_activity() permname = Permission.POST_NOTIFICATIONS # don't ask again if user previously denied; yes, True=denied (!) if activity.shouldShowRequestPermissionRationale(permname): trace('np=> User denied post_notification') nextop(); return # explain the system dialog first, then open it msg = ('This app uses Android\'s notifications permission for services.' '\n\n' 'In the following dialog, please grant this app this ' 'permission. This will allow this app to post a notification ' 'that lets you know when a foreground service is running ' 'to process a Main-tab action.' '\n\n' 'The service will work whether you grant this permission or not, ' 'and you can change this permission option in your phone\'s ' 'Settings panels at any time.') self.info_message(msg, nextop=lambda: (request_permission(Permission.POST_NOTIFICATIONS), nextop()) ) def verify_paths(self, frompath, topath): """ -------------------------------------------------------------------- Paths are editable and drives can be removed: verify that both name existing directories, and are accessible to the app. Either can be None: NAME uses only FROM, UNDO uses just TO. Storage permission is checked both here by Main-tab actions, and prior to opening popup by folder chooser. Folder access is checked both here (post permission) and on folder-chooser storage buttons. Subtle: permission is now checked here instead of post confirmation, else the listdir here may fail due to no permission without reasking for permission, and the restart prompt must follow granting perms. Though should happen only for MANUALLY typed paths: the chooser asks for perms before opening its popup too... UPDATE [1.1.0] avoid lags/hangs for inaccessible paths prior to Main action runs. This may arise for network or other drives unmounted since a chooser pick, as well as arbitrary paths entered manually. UPDATE [1.1.0]+ tweak the message to clarify that an app restart is not needed on macOS (probably), and qualify the opening as "on Android" because it won't apply anywhere else. See reset_main_picker_path(). ---- UPDATE [1.2.0] On Android only, avoid exposing the app's source code for a manually entered path in FROM. Do likewise to avoid harming app source code for manual entries in TO. Else, for example, a SYNC/COPY with a manual FROM='..' copies the app's code in full, and the same actions with TO='..' destroys the app. Formally, both absolute and relative paths may refer to the app's code when input manually (i.e., not by chooser pick). Because the app first chdir()s into the ./mergeall subfolder when actions are run in both thread and service modes on Android, the following relative paths apply: -=works, *=prevented by simple abspath==abspath, x=inaccessible - name is a subfolder in mergeall/ (okay to expose) - '.' is mergeall/ (okay to expose, THOUGH a mod version) * '..' is the app's full code (bad) * '../..' is a folder with app/ (the code: bad), runcounter, and settings x '../../..' is inaccessible, and issues an error popup And the following absolute paths apply both post- and pre-chdir() (checked pre-chdir() here, when '.' is still the app's code folder): - (mergeall) /data/data/com.quixotely.usbsync/files/app/mergeall * (. = cwd) /data/data/com.quixotely.usbsync/files/app [works] * (..) /data/data/com.quixotely.usbsync/files [works] - (../..) /data/data/com.quixotely.usbsync [works! =rel ../../..] x (../../..) /data/data [inaccessible] BUT: all the abs paths also work with /data/user/0 instead of /data/data, and this form is not detected by a simple comparison to abspath('.'), and Python's os.path.realpath() does NOT map either form to the other, and os.path.relpath() does NOT return the same string for each form. THOUGH: os.path.samefile(form1, form2) is True on Unix (incl Android) per inode testing (via stat()) that is immune to all path-form diffs. HENCE => must either: (1) Use [hangable] samefile(input-path, *code-folders) instead of ip == cf (2) Punt on '..'s and fail if split(abspath))[1] == split(appprivate)[1] GO WITH #1, because #2 is hampered by the fact that different filesystem roots may lead to the same place independently (split[1] isn't enough). #1 requires try-any_nohang() to avoid unlikely hangs for the stat call, as well as except catches for inaccessible roots above code (may vary); it may also burn time on paths that don't make sense, but phones are fast. Caveat: try_any_nohhang() will return False if exc, timeout, or false. The exc and false cases are what we want, but a timeout may occur for timeout when the path refers to a private folder (a false negative). Punt; this check seems hardly worth the effort already put into it... See also storage_path_app_private() for more on the folder structure; per logcat: (PPUS) app-priv: /data/user/0/com.quixotely.usbsync/files. Defunct note: this also assumes that os.path.abspath() won't hang: it runs only os.getcwd() plus purely syntactic path transforms. Alt: disallow _all_ relative paths, but this seems too extreme, and doesn't handle absolute-path refs to the code folder or parents. This is only an issue on Android, because Buildozer apps don't do anything about hiding source automatically: .py files are in the run folder, which is app private, but completely open to the app itself (and thus exposed to users via unchecked pathname entries!). PC apps are built with PyInstaller, which normally bundles just bytecode in formatted exes or files, and ".." is just the app unzip folder (Windows+Linux) or application folder (macOS)... with the following glaring caveat. Also in [1.2.0], avoided exposing the app's source-code files in the Windows PyInstaler exe, per this from pc-build/(windows-spec-file): # # [ML] [1.2.0] Tighten up source-code visibility with excludes=[...] # in added Tree(), so raw .py source-code files are not exposed in # PyInstaller _MEI* Temp unzip folders (an undocumented side-effect # of a Tree() spec-file insert required+prescribed by Kivy!). # # This applies only to the GUI's code (Mergeall's code used for # file ops is fully open source); and only to Windows (Linux and # macOS exe/dir don't add a Tree('.') and so don't include .pys, # and Andoid's Buildozer has different source-code policies). # # PyInstaller used to have a '--key' encrypted-bytecode option, # but dropped it in 6.0 because it was too weak: keys were shipped # in PI exes, and Github has tools to extract+decrypt+decompyle. # Encryption or not, PyInstaller packages code, but hides it from # casual eyes only; this might encourage open source and free. # (Ref: https://github.com/pyinstaller/pyinstaller/pull/6999). # # Also closed up a code leak for manually input paths on Android. # In the end, this all may be a moot point: it's probaby easier # to make a workalike app than steal+mod+rebuild this one's vast # Python code, and Play payola and audience naiveté remain hurdles. # UPDATE [1.2.0]: also changed to catch invalid relative paths in the GUI, instead of letting them reach and fail in mergeall.py. This can happen for paths like '../app' which are valid in the cwd here, but not after chdir to mergeall/ to run the action: must check in the context if the mergeall/ forlder for relative paths. Such paths are not kicked out as app-private, because they are invalid in mergeall/, and hence skipped by app-private tests. Current coding runs os.chdir() > once, but this is too fast to matter. -------------------------------------------------------------------- """ # confirm permission or ask again before listdir, force retry if ask if not self.get_storage_permission(): return False # verify access next checks = [('FROM', frompath), ('TO', topath)] # check from and to for (kind, path) in checks: if path == None: continue # skip if unused by action # [1.2.0] checks vary is_abs_path = os.path.isabs(path) # check cwd does not matter is_rel_path = not is_abs_path # check relative to mergeall/ # check isdir # os.path.isdir(path) can also hang on Windows [1.1.0] if is_rel_path: os.chdir('mergeall') # [1.2.0] isdir = self.try_isdir_nohang(path) if is_rel_path: os.chdir('..') # [1.2.0] if not isdir: self.info_message('%s is not a valid folder path.' % kind, usetoast=True) # [1.1.0] expanded return False # cancel action # check app-private # [1.2.0] disallow app's code folder as FROM or TO if RunningOnAndroid: exposed = False samefile = os.path.samefile if is_abs_path: # chdir won't matter for p in ['mergeall', '.', '..', '../..']: if self.try_any_nohang(samefile, path, p, onfail=False): exposed = True break else: # chdir will matter os.chdir('mergeall') for p in ['.', '..', '../..', '../../..']: if self.try_any_nohang(samefile, path, p, onfail=False): exposed = True break os.chdir('..') if exposed: self.info_message('%s cannot be app private.' % kind, usetoast=True) return False # cancel action # check listdir """ try: os.listdir(path) except: """ # same, but use timeouts to avoids lags for inaccessible drives [1.1.0] if is_rel_path: os.chdir('mergeall') # [1.2.0] listed = self.try_listdir_nohang(path) if is_rel_path: os.chdir('..') # [1.2.0] if not listed: # hung (inaccessible), or immediate fail (unmounted?) # differs from chooser: about folder, not drives list noaccessmsg = ( '%s is not accessible.' '\n\n' 'If you just granted storage permission on Android, ' # [1.1.0]+ 'please try restarting this app now.' '\n\n' 'Otherwise, retry after ensuring that your ' 'device has finished mounting your drive in full.' ) if RunningOnMacOS: noaccessmsg += ( '\n\n' 'On macOS, you may be able to simply rerun the last ' 'request if you just handled the permission popup.' # [1.1.0]+ ) if not RunningOnAndroid: # [1.1.0] added noaccessmsg += ( '\n\n' 'On PCs, also check folder permissions and locks, ' 'and network-drive status.' ) self.info_message(noaccessmsg % kind, usetoast=False) return False # cancel action return True # all passed: proceed with action, if user confirms def check_older_android_removeable_updates(self, updatepath): """ -------------------------------------------------------------------- Because Android's SAF sucks in full. (And the Android 10- audience is shrinking rapidly.) ((And most users will sync only PC-to-phone anyhow.)) Prohibit non-appfolder removable-drive updates in Android 10- for SYNC, UNDO, COPY, and NAME, which would all fail with error messages in the logfile. Called on action picks: this can't be addressed in the chooser, because it's okay to read non-app folders on removables on all Androids, the action selection is not known until the popup is closed, and users can manually type/paste paths into Main. The chooser also doesn't preset paths to app folder paths instead of drive roots for removables on 10-, because it's valid and normal to choose other folders on these drives for reads (e.g., PC=>phone syncs). -------------------------------------------------------------------- """ message = ('CAUTION: you have selected a removable-drive folder that ' 'does not support updates on your version of Android, ' 'and a Main-tab action that may modify the folder.' '\n\n' 'Per this app\'s docs, Androids 11 and later allow reads and ' 'updates anywhere on removable drives. Androids 10 an earlier ' 'allow reads in any removable-drive folder, but writes are ' 'supported only in the drive\'s Android/data/com.quixotely.%s.' '\n\n' 'Because any updates outside this folder will fail with a ' 'logfile error message, this action must be cancelled. ' 'Please relocate your content on the drive as needed.') if RunningOnAndroid and self.android_version() <= 10: # through 10/Q, api 29 appsuffix = 'usbsync' if not TRIAL_VERSION else 'usbsynctrial' appfolder = '/Android/data/com.quixotely.' + appsuffix removables = self.storage_names_and_paths_removables() for (rname, rpath) in removables: if updatepath.startswith(rpath): # removable if updatepath.startswith(rpath + appfolder): return True # proceed: in app folder else: self.info_message(message % appsuffix, usetoast=False) return False # cancel: outside app folder return True # proceed: does not apply or not removable def label_path_string(self, path): """ -------------------------------------------------------------------- [1.1.0] Format path with its volume label. Now shown in action confimation popups, both to remind users of paths, and associate logical label with physical path. This was a compromise: it's important to remind users of the FROM and TO subjects in the popup, and useful to show the label with the path. But displaying the label in Main tab FROM and TO fields is problematic, because these are user-editable fields, and there are few delimeters that would work for all platforms in path strings. E.g., "<label> path" seems ok, but requires special rules and docs for manual edits, and "<> " are valid in Unix file and folder names. Confirmation dialogs are not editable, and require no iffy parses. Uses a most-specific storage label: may != chooser pick for ROOT. Mimic Main chooser's path preselect logic: pick storage by fist prefix match among all storages sorted by decreasing lenth - which orders most-specific first: see do_main_tab()'s preselect for docs. Here, must build all-storages list, because chooser buttons don't exist. In fact, the chooser may not have been run or used at all (the user can enter anything, manually), so cannot cache chooser's latest results to avoid network lags on Windows. A former coding that tried sorted removables, then app, then shared, then root, may have guessed poorly on Windows (where ROOT=system drive is removed from drives, but PC (home) might not be nested in ROOT). The global sort handles all cases, including arb nested Linux mounts. [1.1.0]+ dec23: drop enclosing name parens if present, because caller always adds more; else Windows '(Removable)' shows as odd '((Removable))', and likewise for '(Remote)' for network drives and WSL2 Linux mounts. [1.1.0]+ (feb24) recoded to be more robust, based on the following ops: >>> x = '(Removable)' >>> x[1:-1] if x.startswith('(') and x.endswith(')') else x 'Removable' >>> x = 'T7SSD' >>> x[1:-1] if x.startswith('(') and x.endswith(')') else x 'T7SSD' >>> x = '((Hmm))' >>> x[1:-1] if x.startswith('(') and x.endswith(')') else x '(Hmm)' >>> x.strip('()') 'Hmm' >>> x = '' >>> x[1:-1] if x.startswith('(') and x.endswith(')') else x '' >>> x[1:-1] if x[0] == '(' and x[-1] == ')' else x IndexError: string index out of range >>> x = '()' >>> x[1:-1] if x.startswith('(') and x.endswith(')') else x '' >>> x[1:-1] or x if x.startswith('(') and x.endswith(')') else x '()' Risk: thus will clash with another drive here (only) if a user labels a drive 'Removable' (but so will 'Untitled' on macOS, and very unlikely), and may drop outer parens coded by users (but why would they do that?). -------------------------------------------------------------------- """ # Get all storage types shared = self.storage_path_shared() appspec = self.storage_path_app_specific() drives = self.storage_names_and_paths_removables() # before root for Windows root = self.storage_path_root() # we're punting on Docs # Build list of all names+paths to mimic choose's storage buttons # start with removables, sans system drive on windows # not in APP|PHONE on Android nor PC on Windows|macOS, may be in PC on Linux storagesbypathlen = drives # app is moot for pc, nested in phone on android if appspec != None: storagesbypathlen.append(('APP', appspec)) # shared storage on android, home (account) on pcs, never None storagesbypathlen.append((('PHONE' if RunningOnAndroid else 'PC'), shared)) # subsumes all on unix and android, system drive on windows, poss None on android if root != None: storagesbypathlen.append(('ROOT', root)) # sort storage types by decreasing path length = longest (most specific) first storagesbypathlen.sort(key=lambda store: len(store[1]), reverse=True) # use absolute path comparison: user may enter anything abspath = os.path.abspath(path) # Select storage type by matching path prefix, caller formats for (stname, stpath) in storagesbypathlen: if abspath.startswith(stpath): if stname.startswith('(') and stname.endswith(')'): # [1.1.0]+ dec23+ stname = stname[1:-1] or stname # unless is '()' return (stname, path) # [1.2.0] for allowed android /data app-private: __pycache__ in ./mergeall/ return ('?', path) # punt: unexpected manual entry outside model? """DEFUNCT---- # removables: not nested in app or phone app on android, nor in in pc (home) # on windows pcs, but might be mounted/nested in pc on linux - test first; # sort by decr path len on linux: 'os' at /media/me, drives at /media/me/xxx; # windows: root=system drive is removed from drives, so won't do pc=home here; # see also preselect in chooser dialog, and origins in names-and-paths call: if RunningOnLinux: # linux only, not android drives.sort(key=lambda drive: len(drive[1]), reverse=True) # longest first for (remname, rempath) in drives: if path.startswith(rempath): return display % (remname, path) # app next: nested in phone=shared on android, moot on pcs if appspec != None and path.startswith(appspec): return display % ('APP', path) # user account (home) on pcs, shared storage on android if path.startswith(shared): return display % ('PHONE' if RunningOnAndroid else 'PC', path) # root last: subsumes all on unix and android, system drive on windows if root != None and path.startswith(root): return display % ('ROOT', path) DEFUNCT----""" def format_paths(self, frompath=None, topath=None): """ -------------------------------------------------------------------- [1.1.0] Action confirm-display helper. Nit: this can't use unicode arrows in info-msg font. Also nit: path wrapping on narrow phones can look a bit subpar, but no other format seemed better, and manual splits are right out. E.g., indenting name+path on new line is ideal sans wrapping but looks jumbled wrapped; and uppercase names seem lost in both name+path on new line sans indent, and only name on FROM/TO line sans parens or spaces with path unindented on new line. Also tried "Path:" on new path line: busy, redundant. The Format used calls out name well, and suffices on both larger screens like PCs and foldables, and narrow phones. -------------------------------------------------------------------- """ display = '' if frompath: display += 'FROM: (%s)\n%s\n\n' % self.label_path_string(frompath) if topath: display += 'TO: (%s)\n%s\n\n' % self.label_path_string(topath) return display # SYNC------------------------------------------------------------- def toggle_undo(self, saving_backups, topath): """ On user confirmation in SYNC confirm-run dialog. Disable UNDO if confirm run sans backups, renable if bkps on. Changing the TO field in the GUI renables UNDO button via code in the .kv file: different dir makes UNDO's current state moot. The .kv handler fires for both manual and chooser mods to TO. [1.1.0] But not if self.script_running, else UNDO will come on while an action is in progress, and user may start > 1 action! Instead, on path-text edit, the .kv file reenables UNDO immediately iff no action is running, and sets undo_verboten to False always so UNDO will be enabled on script exit (and therafter). """ self.undo_verboten = not saving_backups # toggle in running app try: undoname = osjoin(topath, '__bkp__', UNDOABLE_FILE) # flag file for next run if not saving_backups and osexists(undoname): # saved in in TO/__bkp__ os.remove(undoname) elif saving_backups and not osexists(undoname): bkpspath = osjoin(topath, '__bkp__') if not osexists(bkpspath): # if never saved backups os.mkdir(bkpspath) undofile = open(undoname, 'w') # empty file: flag only undofile.close() except: self.info_message('Cannot manage undoable file.', usetoast=True) def do_sync(self, frompath, topath): trace('do_sync') if not self.verify_paths(frompath, topath): return if not self.check_older_android_removeable_updates(topath): return message = ('About to run a SYNC action...' '\n\n' '%s' 'This will change your TO content folder to be the same ' 'as FROM, by updating TO only for changes in FROM.' '\n\n' '%s' '\n\n' 'This may change TO, but not FROM.' '\n\n\n' 'Continue with run?') optionals = [] if self.ids['backupscheckbox'].active: optionals.append('-backup') if self.ids['skipcruftscheckbox'].active: optionals.append('-skipcruft') # warn about runs sans backups saving_backups = self.ids['backupscheckbox'].active warnbkps = ('Backups for changes made by this SYNC will be saved to your ' 'TO/__bkp__ and can be rolled back by UNDO.' if saving_backups else 'CAUTION: this sync cannot be rolled back with UNDO ' 'because backups are turned off in the Config tab.') # if confirm: setup undo, globals for threads, maxbackups for thread|process self.confirm_action( # show labels+paths [1.1.0] message = message % (self.format_paths(frompath, topath), warnbkps), opname = 'SYNC', script = osjoin('mergeall', 'mergeall.py'), cmdargs = [frompath, topath, '-auto', '-quiet'] + optionals, postcon = lambda: (self.toggle_undo(saving_backups, topath), self.init_mergeall_globals(), self.update_mergeall_configs_maxbackups() )) # SHOW------------------------------------------------------------- def do_show(self, frompath, topath): trace('do_show') if not self.verify_paths(frompath, topath): return message = ('About to run a SHOW action...' '\n\n' '%s' 'This will report FROM/TO differences quickly, ' 'by using timestamps, sizes, and structure, ' 'instead of full content.' '\n\n' 'This will not change anything in FROM or TO.' '\n\n\n' 'Continue with run?') optionals = [] if self.ids['skipcruftscheckbox'].active: optionals.append('-skipcruft') self.confirm_action( message = message % self.format_paths(frompath, topath), opname = 'SHOW', script = osjoin('mergeall', 'mergeall.py'), cmdargs = [frompath, topath, '-report', '-quiet'] + optionals) # UNDO------------------------------------------------------------- def discard_latest_backup_folder(self, latestbkp): """ Postop call in thread: rename latest __bkp__ so N UNDOs go back in time. Each later UNDO will find and rollback the next-most-recent in __bkp_. A special case, but this would otherwise require a complex Mergeall mod. The rename tacks a prefix onto the latest __bkp__ subfolder's name. This removes this backup from future consideration here, but also keeps it in the set of subfolders auto-cleaned in mergeall's backup.py. Could be run on EOF in UI thread instead, by passing to timer callback. UPDATE: confirm message now has both postcon (in gui thread, before script) and postscr (in gui|script thread, after script). Run file ops in script thread if possible (can't for service) to avoid pausing gui. Why not nested func: also called by app restart if service still running. This must be called after script exit, and won't survive app kill+restart. UPDATE: no longer used for app restart - see check_for_running_service(). """ try: os.rename(latestbkp, (latestbkp + '--UNDONE')) except: self.info_message('Cannot rename latest backup.', usetoast=True) def do_undo(self, topath): trace('do_undo') if not self.verify_paths(None, topath): return if not self.check_older_android_removeable_updates(topath): return message = ('About to run an UNDO action...' '\n\n' '%s' 'This will roll back your TO content folder ' 'to be what it was before its latest SYNC.' '\n\n' 'UNDO assumes SYNCs were run with backups enabled in ' 'the app, and is generally prohibited if not.' '\n\n' 'Successive UNDOs roll back successively older syncs, ' 'but only if prior syncs saved backups.' '\n\n' 'You can undo an UNDO by simply rerunning the SYNC ' 'that was rolled back.' '\n\n' 'This may change TO, but not FROM (FROM is ignored).' '\n\n\n' 'Continue with run?') # find latest backup folder tobkp = osjoin(topath, '__bkp__') # backups in TO bkppatt = 'date??????-time??????' # ignore '--UNDONE's here allbkps = glob.glob(osjoin(tobkp, bkppatt)) # mergeall uses 'date*-time*' # bail if no backups - first, else undofile test fires if not allbkps: self.undo_verboten = True self.ids.undobutton.disabled = True self.info_message('No usable backups in TO.', usetoast=True) return # bail if TO's latest sync did not save backups (and save flag file); # for first UNDO in session: never get here after a SYNC sans bkps; # nothing can be done for older bkps: user may mod folder arb; # changing the TO field in the GUI renables UNDO button via .kv; undofile = osjoin(topath, '__bkp__', UNDOABLE_FILE) if not osexists(undofile): self.undo_verboten = True self.ids.undobutton.disabled = True self.info_message('No backups for latest sync in TO.', usetoast=True) return # bail if bkp not a dir (user can mod folder) latestbkp = sorted(allbkps)[-1] # last=newest if not os.path.isdir(latestbkp): self.undo_verboten = True self.ids.undobutton.disabled = True self.info_message('No usable latest backup in TO.', usetoast=True) return optionals = [] if self.ids['skipcruftscheckbox'].active: optionals.append('-skipcruft') # no -backup: breaks next UNDO, and SYNC == UNDO of an UNDO self.confirm_action( message = message % self.format_paths(topath=topath), opname = 'UNDO', script = osjoin('mergeall', 'mergeall.py'), cmdargs = [latestbkp, topath, '-restore', '-auto', '-quiet'] + optionals, postcon = self.init_mergeall_globals, postscr = lambda: self.discard_latest_backup_folder(latestbkp)) # COPY------------------------------------------------------------- def do_copy(self, frompath, topath): trace('do_copy') if not self.verify_paths(frompath, topath): return if not self.check_older_android_removeable_updates(topath): return # make cpall always create from's root as a subfolder in to; # cpall allows it to be made manually and already exist, but # this can't be distinguished from overwrites that will fail, # and to must aleady exist to pass verify_paths() checks here; # also must avoid same-folder copies, else copies fall into # infinite loops and die (copying self into self); cpall.py # does this already, but we're modding the path early here; # abspath()==abspath() nor os.path.realpath() may suffice; if hasattr(os.path, 'samefile'): samepath = os.path.samefile(frompath, topath) # inode: Unix+Android else: samepath = os.path.abspath(frompath) == os.path.abspath(topath) if samepath: self.info_message('Cannot copy a folder into itself.', usetoast=True) return # [1.1.0] actually, need to test for path prefix match of FROM in # TO, not just samefile, else recursive (until path limit reached): # copying A/B parent into A/B, A/B/C, A/B/C/D, or A/B/* will loop! # samefile test above is mostly subsumed, but it may check inodes; if os.path.abspath(topath).startswith(os.path.abspath(frompath)): self.info_message('Cannot copy a folder into its own subfolder.', usetoast=True) return origtopath = topath # save for confirm display contentroot = os.path.split(frompath)[-1] # same as basename topath = osjoin(topath, contentroot) # okay to do pre-confirm # and don't allow if appended TO exists: overwrites fail on some # platforms, and this also catches the case of copying A/B into A; # this test also catches from and appended to being the same path if osexists(topath): self.info_message('Cannot copy to an existing folder.', usetoast=True) return message = ('About to run a COPY action...' '\n\n' '%s' 'This will copy your FROM content folder into TO completely.' '\n\n' 'FROM\'s root folder, %s, will be created as a new subfolder ' 'inside TO automatically.' '\n\n' 'Because this copies in full, it may run slowly.' '\n\n' 'This will change TO, but not FROM.' '\n\n\n' 'Continue with run?') optionals = [] if self.ids['skipcruftscheckbox'].active: optionals.append('-skipcruft') # TBD: -v dirs or -vv dirs+files; no -u: flush=no-op self.confirm_action( message = message % # can't %= in kw args (self.format_paths(frompath, origtopath), contentroot), opname = 'COPY', script = osjoin('mergeall', 'cpall.py'), cmdargs = [frompath, topath, '-vv'] + optionals) # DIFF------------------------------------------------------------- def do_diff(self, frompath, topath): trace('do_diff') if not self.verify_paths(frompath, topath): return message = ('About to run a DIFF action...' '\n\n' '%s' 'This will compare your FROM content folder to TO deeply, ' 'using byte-for-byte comparisons of each common item.' '\n\n' 'Because this compares in full, it may run slowly.' '\n\n' 'This will not change anything in FROM or TO.' '\n\n\n' 'Continue with run?') optionals = [] if self.ids['skipcruftscheckbox'].active: optionals.append('-skipcruft') # no -u: flush=no-op here self.confirm_action( message = message % self.format_paths(frompath, topath), opname = 'DIFF', script = osjoin('mergeall', 'diffall.py'), cmdargs = [frompath, topath, '-quiet'] + optionals) # NAME------------------------------------------------------------- def do_name(self, frompath): """ Original conception was a configurable prestep for SYNC and COPY, but it's difficult to wedge two scripts into the thread launcher code, and fixer's output must be called out from the others'. TBD: fixer's report-only mode would be nice, but it's console. As is, running NAME seems a bit scary (even for its creator!). UPDATE: this action now uses a custom confimation dialog that lets users run the target script in report or update mode, or cancel. This convolutes the confim call (it now allows preconfirms, else confirmed() must be unnested), but there is no other good option: a Config-tab report|update setting doesn't make sense, because this may be chosen on every NAME run (other Config action settings rarely, if ever, change), and the GUI's too cramped in any event. """ trace('do_name') if not self.verify_paths(frompath, None): return if not self.check_older_android_removeable_updates(frompath): return message = ('About to run a NAME action...' '\n\n' '%s' 'This action fixes any nonportable filenames in FROM ' 'to ensure that your content arrives intact in TO.' '\n\n' 'In update mode, all nonportable characters in FROM\'s ' 'filenames will be replaced with an underscore, so they ' 'can be transferred to other devices.' '\n\n' 'In report mode, nonportable filenames in FROM are ' 'displayed in the logfile, but not changed. An initial ' 'report-mode run is advised, as this action has no undo.' '\n\n' 'Update mode may change FROM, but not TO (TO is ignored).' '\n\n\n' 'Please select a run mode to continue.') def confirmed_report(popupcontent): self.confirm_action( message = None, # preconfirmed opname = 'NAME', script = osjoin('mergeall', 'fix-nonportable-filenames.py'), cmdargs = [frompath, '-report'], stdin = 'y\n', popupcontent=popupcontent) def confirmed_update(popupcontent): self.confirm_action( message = None, # preconfirmed opname = 'NAME', script = osjoin('mergeall', 'fix-nonportable-filenames.py'), cmdargs = [frompath], stdin = 'y\n', popupcontent=popupcontent) confirmer = ConfirmNameDialog(message=message % self.format_paths(frompath), onrunreport=confirmed_report, onrunupdate=confirmed_update, oncancel=self.dismiss_popup) self._popups.push(Popup(title='Choose NAME Mode', content=confirmer, auto_dismiss=False, # ignore taps outside size_hint=(0.9, 0.8))) # wide for phones self._popups.open() #======================================================================= # UTILITIES #======================================================================= def get_android_activity(self): """ Common preamble for pyjnius java-api code: the main activity (screen) In build code: self.activity_class_name = u'org.kivy.android.PythonActivity' 'ACTIVITY_CLASS_NAME': self.ctx.activity_class_name """ pythonact = autoclass(ACTIVITY_CLASS_NAME) mActivity = pythonact.mActivity activity = cast('android.app.Activity', mActivity) return activity def get_android_context(self, activity=None): """ Common preamble for pyjnius java-api code: the app's context (info) Alts: from android.config import ACTIVITY_CLASS_NAME PythonActivity = autoclass(ACTIVITY_CLASS_NAME) activity = PythonActivity.mActivity currentActivity = cast('android.app.Activity', activity) context = cast('android.content.ContextWrapper', currentActivity.getApplicationContext()) PythonActivity = autoclass('org.kivy.android.PythonActivity') currentActivity = cast('android.app.Activity', PythonActivity.mActivity) context = cast('android.content.Context', currentActivity.getApplicationContext()) """ if activity == None: activity = self.get_android_activity() context = cast('android.content.Context', activity.getApplicationContext()) return context @mainthread def info_message(self, pmsg, usetoast=False, longtime=False, nextop=None): """ -------------------------------------------------------------------- Show an info message as an Android Toast, or in-GUI overlay Popup. pmsg is the text to show+scroll in the popup: a Python string in toast. Uses an Android Toast display if usetoast on Android: 2-lines max. @mainthread is required to run graphics updates in main/GUI thread only. @run_on_ui_thread is apparently just for Android things like Toast. It's unclear why we're not in the main/GUI thread here like show_pick(), but... Pass nextop to chain one next dialog after the info popup: any callable. Caution: this doesn't support >1 overlapping modal dialogs as is (was). UPDATE: yes it does - Popups are now stacked for overlaps, and also allow for double taps without silently closing covered popups (see _PopupStack). Also now uses a shorter popup if usteoast and not on Android - no reason to take up most of the window for 1 cryptic line. This might also use macOS slide-down popups, but Kivy doesn't support them? (kivyMD?; tbd). UPDATE: the text is now scrollable in .kv, so go a bit smaller everywhere. UPDATE [1.1.0]: delay the setting of the popup's text content until the next frame, to avoid glitchy lag on slower phones. Else, the text displays as a scrunched single column initially, before being drawn in full. This was glaring on only one older/slower test phone (2018 Note 9), but may be an issue on lower-end phones in general. The delayed set has no noticeable impact on faster phones, and is the same solution used for larger texts in the Help and About tabs: see on_start() and on_tab_switch(). UPDATE [1.1.0] scale the popup per message size. Especially for narrow phones, 90% wide (link confirmation+chooser) where warranted will help. -------------------------------------------------------------------- """ if usetoast and RunningOnAndroid: # the arguably silly Android popup self.show_toast(pmsg, longtime) else: # toast is limited: make me an in-GUI modal popup if not nextop: canceller = self.dismiss_popup # close Info: modal, basically else: canceller = (lambda popupcontent: (self.dismiss_popup(popupcontent), nextop())) # shorter for one-liners # and wider for phones if 'big' (most are tall or wide) [1.1.0] # sizer = (0.8, 0.5) if usetoast else (0.8, 0.8) tallmsg = pmsg.count('\n') > 3 widemsg = any(len(line) > 30 for line in pmsg.splitlines()) swidth = 0.9 if widemsg else 0.8 sheight = 0.8 if tallmsg else 0.5 sizer = (swidth, sheight) content = InfoDialog(oncancel=canceller) # not message=pmsg [1.1.0] self._popups.push(Popup(title='Info', content=content, auto_dismiss=False, # ignore taps outside size_hint=sizer)) # sized per message, (x, y) self._popups.open() def set_info_text(dt): content.message=pmsg # closure: enclosing scope Clock.schedule_once(set_info_text) # delay to avoid lag [1.1.0] @run_on_ui_thread def show_toast(self, pmsg, longtime=False): """ Show an info message as an Android Toast. Highly limited popup for short messages only. These can overlap poorly if longtime=True. Call this directly, or info_message(usetoast=True). See also: from kivymd.toast import toast """ # 2-line max (and should be narrow) pmsg = pmsg.strip().split('\n') pmsg = '\n'.join([pmsg[0], pmsg[-1] if len(pmsg) > 1 else '']) # Android API bit context = self.get_android_context() String = autoclass('java.lang.String') jmsg = cast('java.lang.CharSequence', String(pmsg)) Toast = autoclass('android.widget.Toast') duration = Toast.LENGTH_LONG if longtime else Toast.LENGTH_SHORT Toast.makeText(context, jmsg, duration).show() def open_web_page(self, url): """ The Android Intent is automated by android package: https://github.com/kivy/python-for-android/blob/develop/ pythonforandroid/recipes/android/src/android/_android.pyx What webbrowser invokes: Intent = autoclass('android.content.Intent') Uri = autoclass('android.net.Uri') intent = Intent() intent.setAction(Intent.ACTION_VIEW) intent.setData(Uri.parse(url)) activity = self.get_android_activity() activity.startActivity(browserIntent) Alt scheme from pydroid 3 guis (where it fails): cmd = 'am start --user 0 -a android.intent.action.VIEW -d %s' % HELPURL os.system(cmd) """ webbrowser.open(url) # yes, that's it (for PCs too) def android_version(self): """ Get version of Android on device running the app. Cached for speed, though likely relatively trivial. This is android.os.Build.VERSION.RELEASE in Java; SDK_INT may be botched if rooted and nonstandard (?). Subtly, VERSION is a nested class, not a Build field: developer.android.com/reference/android/os/Build developer.android.com/reference/android/os/Build.VERSION Note: Python's sys.sys.getandroidapilevel() is the _build_ time API, not _run_ time, and won't suffice for checking the host device's version. E.g., Termux is always 24 today, on Android 10 and 13 phones. This seems useless for most apps' use cases, but host #s require pyjnius code below. """ if hasattr(self, 'cache_android_version'): return self.cache_android_version else: VERSION = autoclass('android.os.Build$VERSION') apiversion = VERSION.SDK_INT # api/sdk version number, e.g., 31, 33 andversion = VERSION.RELEASE # android version number, e.g., 12, 13 trace('Host Android:', apiversion, andversion) # str may be x, x.y, x.y.z, or ? - drop all but x or x.y while andversion.count('.') > 1: andversion = andversion[:andversion.rfind('.')] numandversion = float(andversion) self.cache_android_version = numandversion return numandversion #======================================================================= # STORAGE PERMISSIONS: ALL FILES ACCESS (11+), LEGACY (~10), MACOS #======================================================================= def get_storage_permission(self): """ -------------------------------------------------------------------- Verify or request All Files Access if running on Android 11+, else old-style legacy. Called on each startup to verify|request permission, and again by Main filechoosers to verify permission or request it again, as well as confirmed Main actions in case the user types paths manually. Returns True if permission already held, else False if the permission request Setting screen or dialog was opened to ask the user to grant. If False, the reask cancels the Main tab's filechooser or action; this forces users to retry both, and ensures that choosers and actions will never open sans permission. Return value is ignored at startup. Manually entered paths are verified for being a directory and accessible first, so no invalid paths should ever reach Main actions; but ask to allow permission grants anyhow in the unlikely event that the user types a manual path that's accessible sans permissions (probably not, but...). This is not called by logfile operations: if the app cannot create or access the logfile folder, log ops will fail with toasts, but don't ask for permissions until a Main chooser or action, or an app rerun. The return value is ignored when invoked on startup from the App class via self.root, which refers to the .root class created by the kv file's code automatically. This might be staticmethod to skip root, though self is used for the initial info popups. Storage permission details: - This app targets Android 12/S (api 31), because this is the base requirement of the Play store today (not for sideloading). - This app's minimum support is Android 8/Oreo (api 26), because file modtimes didn't work before that. - Permissions use All Files Access on Android 11/R (api 30) and later, but legacy permissions from Android 8/oreo..10/Q (api 29). - Permission vary slightly: Android 11+ can access Downloads/ sans user grants, but pre-11 cannot (this impacts logfile operations). Permissions requires both old and new permissions in the manifest (per buildozer.spec), and run-time checks and requests here. Both permissions require user interaction; A-F-A is a prompted Settings page, old-style is a system dialog, and either may denied by users. This dual-permission support assumes that the USB list from storage_names_and_paths_removables() works before 11 too (it does, but equires different methdod before and after 11). There is no All Files Access permission or Setting before 11, and the 11+ /mnt/media_rw/uuid may not work before 11 either. All Files Access restores POSIX and file-path access for shared and USB storage, and is roughly the same as per-11 legacy storage permission (more info ahead). What a convoluted mess, eh? NOTE: permmission results cannot be cached for speed, because the user can revoke them at anytime in Settings - even while app is running. Must check anew before every op that needs them. UPDATE: macOS may require similar permission settings too, but this app prompts the user just once, because, unlike Android, cannot verify the setting accurately on macOS. LATER: macOS Full Disk Access now seems pointless for user folders; the o/s asks users for specific-folder permission. ==== UPDATE: Android USB storage permissions' effect varies by version: - In Android 11+, All files access grants read+write access to shared stroage, as well as the entire USB drive (though mergeall.cpall had to be patched here to ignore a spurious chmod exception in Pythn's shutil.coptstat for exFAT and FAT32 drives on these Andoids only). - In Android 10-, READ+WRITE (or WRITE only) grants access to shared storage, as well as read access to the entire USB drive, but *NOT* write access to the full USB drive. The app gets USB write access only to its own Android/data/appname folder on USB drives, and cannot write to other USB folders except with Storage Access Framework (SAF), a horribly slow, convoluted, and proprietary Java/Android library that requires content URIs instead of file-path and POSIX access. WRITE probably doesn't enable anything on 10- except shared storage (Mergeall's GUI can write in pydroid3's USB app folder sans perms), though it's still required by this app for that. Run1 message updated. Since SAF is a nonstarter here, support full bidirectional syncs on Android 11+, but liited support on Anddroids 10-: both to-phone syncs from arbitrary folders from-phone syncs in app USB folders only. -------------------------------------------------------------------- """ if not RunningOnAndroid: # moot on PCs (and don't re-ask on macOS) return True else: if self.android_version() >= 11: return self.get_all_files_access_permission_11plus() else: return self.get_legacy_storage_permission_pre11() def get_legacy_storage_permission_pre11(self): """ On phones running Android 8..10, ask for the old-style storage permission, if not already held. """ # old-style pyjnius code is automated in android.permissions here if check_permission(Permission.WRITE_EXTERNAL_STORAGE): # already got one... trace('sp=> Has legacy-storage permission') return True else: # explain the system dialog first, then open it # drop "both your USB drive, as well as" per above msg = ('This app requires Android\'s general storage permission.' '\n\n' 'In the following dialog, please grant this app this ' 'storage permission. This will allow this app to ' 'access your content in shared storage.' '\n\n' 'Whether granted or not, your content will never leave your devices ' 'in this app. See PRIVACY POLICY in the About tab for more info.' '\n\n' 'You can change this permission option in your phone\'s Settings ' 'panels at any time.' '\n\n' 'Note: you may have to restart this app just once to activate this ' 'permission; you\'ll be prompted to do so if required.') self.info_message(msg, nextop=lambda: request_permission(Permission.WRITE_EXTERNAL_STORAGE)) # retry choosers+runs - had to reask user, result unknown sans callback return False def get_all_files_access_permission_11plus(self): """ -------------------------------------------------------------------- Check if this app has been granted All Files Access (a.k.a. MANAGE_EXTERNAL_STORAGE) in Settings, and direct user to the settings screen if not. Run me before folder choosers, and on each app startup - paths can be typed manually sans choosers, and the permission may be disabled in Settings at any time. All Files Access gives POSIX and file-path access to shared storage, as well as the root of the USB drive. This restores the sort of access Android allowed prior to Android 11, though this permission requires both Android 11's API or later, as well as approval for the Play store (but sync apps should get it, and sideloading doesn't need it). File-API access is also slowed by wrappers in 11+, but the hit is unavoidable; generally acceptable on phones; and soon to be >= halved by UFS-4 storage chips. More: https://developer.android.com/training/data-storage/manage-all-files NOTE that, unlike some permissions, this dialog is NOT required: A-F-A is a persistent system setting that can be made by the user anytime and need not be persisted by the app. Turning it on in Settings suffices, but this sends the user there as a convenience. ---- UPDATE: runtime permissions may not be activated until the app is RESTARTED, at least on some Androids/phones. The "fix" for this is to either recode the app radically to not do anything until a Java callback is run for the grant, or programmatically restart the app. The former seems far too much work just for the glitchy Android, and both of these are woefully undocumented by Google and rely on Internet SO/GH gossip that proposes codings which don't work across all Androids and can even vary per vendor. That is: PUNT!! Instead, add this to the docs, note it in the permission dialog, and ask for a restart in an info popup if/when access fails. A single manual restart iff needed is not a whole lot to ask users of a $2.99 app that's free on PCs. And WTH doesn't Android auto-restart apps in this context?! Unless this was meant as a joke, it's difficult to understand... ---- UPDATE: the restart-required issue can currently be recreated only on a Fold4 running Android 13; test devices running Androids 8 through 10 do not exhibit the problem after uninstall+restart+reinstall (though it's not impossible that some app info may be cached anyhow). Android 11 and 12 cannot be tested (except on a Pixel 4a, which seems to wholly botch POSIX USB access even with permissions), but the current best guess is that this was a fixed bug in 8..10, but ongoing in 11+. This app also doesn't need to care; until Android grows more stable (and less advertising based!), it is what it is, and we can expect no more. LATER: Android 12 on Pixel4a does indeed require a post-perm restart too, to access a fat32 drive; it's likely the norm on ALL Androids 11+. This phone required 11=>12 upgrade to support USB drives, and then just fat32. Maybe this is why people don't use Android for anything important; yet? ---- UPDATE [1.2.0] Kivy/p4a's android.permissions module has added the MANAGE_EXTERNAL_STORAGE permission since this code was written (and this app is still using the former Kivy). It's unknown if using this module to check/request this permission works the same as the custom code here, but it seems doubtful; AFA is a custom permission, though Android 13's notification permission above uses this module in part (probably; see check_notification_permission()). -------------------------------------------------------------------- """ """ in Java (and for Android 11+ only): if (!Environment.isExternalStorageManager()) { Intent intent = new Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION); Uri uri = Uri.fromParts("package", getPackageName(), null); intent.setData(uri); startActivity(intent); } """ Environment = autoclass('android.os.Environment') if Environment.isExternalStorageManager(): trace('sp=> Has All-files-access permission') return True else: # explain the setting screen first, then open Settings for app msg = ('This app requires Android\'s All files access permission.' '\n\n' 'In the next screen, please grant this app the All files access ' 'permission by turning its toggle on. This allows this app to ' 'access your content in both your USB drive and shared storage.' '\n\n' 'Whether granted or not, your content will never leave your devices ' 'in this app. See PRIVACY POLICY in the About tab for more info.' '\n\n' 'The toggle screen is part of your phone\'s Settings, and can also ' 'be accessed at any time by searching for "All files access" there.' '\n\n' 'Note: you may have to restart this app just once to activate this ' 'permission; you\'ll be prompted to do so if required.' # # [1.2.0] not so much anymore (a temp state issue on 1 phone?)... # '\n\n' # 'Also note: for reasons unknown, the toggle may take a dozen ' # 'seconds to change on some phones; please wait for the change.' ) self.info_message(msg, nextop=self.open_afa_settings) # retry choosers+runs - had to reask user, result unknown sans callback return False def open_afa_settings(self): """ Part 2, get_all_files_access: open Settings for app after info popup. This is a persistent-Setting screen. This has never been seen to fail on 6 phones running Androids 8~13, but user can still do manually if excs. """ try: activity = self.get_android_activity() context = self.get_android_context(activity) Uri = autoclass('android.net.Uri') Settings = autoclass('android.provider.Settings') Intent = autoclass('android.content.Intent') intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION) intent.setData(Uri.fromParts('package', context.getPackageName(), None)) activity.startActivity(intent) # and it's up to the user: no callback here to check - recheck on choosers except: excmsg = 'Cannot auto-open All files access: open manually in Settings.' self.info_message(excmsg, usetoast=False) def macos_ask_for_full_disk_access(self): """ -------------------------------------------------------------------- On fist app run on macOS, ask the user to enable Full Disk Access in Settings, and route them there automatically. This is similar to All files access on Android 11+, but, unlike Android, there's no way to check if the permission is granted (short of access failures that may have multiple causes), so ask only on the first run, not again later. This is available on macOS Mojave and later, but the macOS app supports only Catalina and later so version is moot. It may also be largely optional, but Apple docs seem to be nonexistent. TBD: why does Mergeall's py2app app not require this permission to access USB drives? There must be a diff in signing or entitlements in py2app (manifest diffs look trivial), but this seems out of this project's control and scope. Mergeall, and this app sans F-D-A, asks for individual folder perms (e.g., Documents, Removables) on access, and lists these in Settings. This app, however, did not ask for Documents and so couldn't write logfiles; Docs is in Settings later for manual enables, but it's safer to prompt the broader F-D-A access up front. Even so, the app has been seen to ask for Document and Removables permission when accessed _with_ F-D-A too. As usual, the rules are convoluted, the docs are thin, and time is limited! Editorial: macOS has also instituted extreme signing requirements that deprecate independent developers. Why are device makers so bent on stifling innovation--and fun--with security lockdowns, for the sake of a clueless straw-person audience that does not exist? The most likely explanations seem paranoia and monetizable points of control. Between this, cruft files, and NFD-Unicode dogma, it's difficult to continue pretending that macOS is an open Unix system... UPDATE: macOS Full Disk Access now seems pointless for user-created content folders. This has been watered down to a weak suggestion in the popup and docs. macOS always asks for individual folder perms on first access, whether FDA is held or not. FDA seems to be for app data and admin files only, though it's stupidly undocumented. UPDATE [1.1.0]+: due to the elimination of hangs, macoOS permission popups may overlay folder-inaccessible info_message popups. Add text to clarify that restarts not required, here and in the all info_message. macOS shouldn't intercept listdir sans app knowledge this way, but... -------------------------------------------------------------------- """ # explain the setting screen first, then open Settings screen msg = ('About macOS storage permissions.' '\n\n' 'As you use this app, macOS will ask you to grant permissions to ' 'folders like Documents and Removable drives when this app first ' 'accesses them. If you don\'t grant these permissions when asked, ' 'you\'ll need to enable them for this app later in Settings. ' 'You do not need to restart the app after each permission popup.' # [1.1.0]+ '\n\n' 'Though optional for typical use, this app may also benefit ' 'from macOS\'s Full Disk Access (FDA) permission. FDA enables ' 'access to private app data like Mail and Messages, as well as ' 'files related to admin settings. This is not normally required ' 'for content folders of your own making, but may be useful in ' 'some scenarios.' '\n\n' 'Toggles for both specific-folder and FDA permissions are in your ' 'PC\'s Settings, Security & Privacy, Privacy (or similar); scroll ' 'down to the entries for FDA or Files and Folders. As a convenience, ' 'this app will open this screen once after this dialog closes.' '\n\n' 'Whether permissions are granted or not, your content will never ' 'leave your devices in this app. See PRIVACY POLICY in the About ' 'tab for more info.') self.info_message(msg, nextop=self.macos_open_full_disk_access) def macos_open_full_disk_access(self): """ This seems a bit iffy, but works in all tests run so far... TBD: unclear if should use Privacy or Privacy_AllFiles: the latter may not work on all, but probably does on Catalina+; Update: this has been seen to fail with longer suffix; punt! """ try: webbrowser.open( 'x-apple.systempreferences:com.apple.preference.security?Privacy') except: excmsg = 'Cannot auto-open privacy Settings: open manually as needed.' self.info_message(excmsg, usetoast=False) # and it's up to the user: no way to verify, so don't ask again #======================================================================= # STORAGE PATHS #======================================================================= # Originally written for Android and its [paths], generalized for PCs. # Hence the odd coding structures and focus on Android in initial docs. # This presents a logical model to users which downplays physical paths, # but still shows them for verification and allows them to be input manually. # With PCS, "shared" is now preferred storage for platform; "app_specific" # is moot on PCs; "root" is system-drive root (e.g., '/' on Unix, "C:\" # on Winsows, something useful on Android if any be); and removable-drive # name lookup is inherently platform specific be run for all devices. def storage_path_app_private(self): """ -------------------------------------------------------------------- [/data/user/0/com.quixotely.usbsync/files, a.k.a /data/data/com...] Not for user content: fully private to app (sans SAF), nuked on uninstall (unless check the toggle added by hasFragileUserData). Used internally to store config settings, run counts, and more?. This is Context.getFilesDir() in Java => the files/ subfolder of app private. The app install is in its (hidden) app/ subfolder. It has at least 2 names on Samsung phones: /data/data, /data/user/0 (and Python tools don't map the two to a common canonical form...). There is also Context.getDataDir(); and Environment.getDataDirectory() for a home folder, which seems to be the parent folder of Context.getFilesDir(), and shouldn't be used (supposedly). -------------------------------------------------------------------- """ if not RunningOnAndroid: if RunningOnMacOS: # tbd: macos may not allow access to app's own folder; use ~/Library # https://learning-python.com/post-release-updates.html#macosquarantine # okay if this exposes the app's run counter: pc versions are free applib = osjoin(os.path.expanduser('~'), 'Library', MACOS_APPPRIV_SUBFOLDER) if not osexists(applib): os.makedirs(applib) # make Library too if needed, vs mkdir return applib else: return '.' # Windows, Linux: cwd, which is app install/unzip folder # original Android code path = android.storage.app_storage_path() trace('app-priv:', path) return path def storage_path_shared(self): """ -------------------------------------------------------------------- [/storage/emulated/0, a.k.a. /sdcard, a.k.a. main|phone|internal] Preferred for content: fully accessible by POSIX+path with All Files Access, (except Android/*: use APP for this app's subfolder), and never auto-removed on app uninstall. Slow, but 2-4X less with UFS-4. This is Environment.getExternalStorageDirectory() in Java. There's also call getDataDirectory(), but this seems to be mostly unused by apps. See also: plyer: autoclass('android.os.Environment').getExternalStorageDirectory().getAbsolutePath() plyer: https://github.com/kivy/plyer/blob/master/plyer/platforms/android/storagepath.py android: https://github.com/kivy/python-for-android/blob/develop/pythonforandroid/recipes/android/src/android/storage.py -------------------------------------------------------------------- """ if not RunningOnAndroid: # probably in C:\ = ROOT on Windows, but? return os.path.expanduser('~') # home: preferred content path on all PCs # original Android code path = android.storage.primary_external_storage_path() trace('shared:', path) return path def storage_path_app_specific(self): """ -------------------------------------------------------------------- [/storage/emulated/0/Android/data/com.quixotely.usbsync/files] Optional for content: in Android/data, mostly accessible, fast, nuked on uninstall (unless check the toggle added by hasFragileUserData in manifest). This creates the app-spec folder if it does not exist, and returns its path. Folder cannot be created by the app manually (os.mkdir) in current Androids. See also: getExternalFilesDirs() returns app-spec on all mounted devices. ---- UPDATE [1.1.0] the first-run hang on Android was finally isolated: there is a ~10 second hang when picking FROM or TO, but only on the first run after a fresh install (post uninstall if needed), and only if a chooser is opened just after attaching a removable drive that has not yet finished mounting. In this case only, Android's getExternalFilesDir() call can hang, on both Androids 10 and earlier, and Androids 11 and later. Surprisingly, this has NOTHING to do with any of the API calls in the removable-drive name fetch ahead. It happens when trying to make the app-specific folder here. This is rare and unlikely in the extreme. It doesn't happen for app runs 2 and beyond, and doesn't happen unless a drive mount is in progress when the chooser is opened, and many users in this case may opt to open a suggested explorer to handle the drive and wait ill it registers there as mounted. The odds of a user attaching a USB in run 1 and opening a chooser without waiting for the mount to finish are _very_ slim. Still, it's a rude welcome. To address, add a timeout to the hanging call here and return None on hangs. This will omit APP storage from the chooser, but only when this happens: chooser opens to and later on run 1 will work fine as will all runs 2+. -------------------------------------------------------------------- """ if not RunningOnAndroid: return None # moot on PCs: do not list in chooser # original Android code # from Java docs: getApplicationContext().getExternalFilesDir("") context = self.get_android_context() """ trace('prehang ', time.ctime()) appfile = context.getExternalFilesDir('') # hangware! trace('posthang', time.ctime()) """ # get path, else None if exc or hang [1.1.0] appfile = self.try_any_nohang(context.getExternalFilesDir, '') if appfile == None: return None # won't appear in choosers till hang over apppath = appfile.getAbsolutePath() trace('app-spec:', apppath, appfile) return os.path.normpath(os.path.abspath(apppath)) def storage_names_and_paths_removables(self, maxnamelen=32): r""" -------------------------------------------------------------------- ['drivename' + /mnt/media_rw/xxx-xxxx] Fully accessible by POSIX and file path with All Files Access permission, and never auto-removed on uninstalls. Return attached USB drive's name and /mnt/media_rw/uuid mount path. The xxxx-xxxx UUID is needed to access the path: can't list media_rw. Returns [(drive-name, drive-path)], which includes USBs and microSDs. Main-tab choosers list all returned removable storage names for picks. The name makes user selection easier: most won't know drive's UUID (this requires a supporting explorer), and many may not grok paths. On Android, the list will have multiple USB drives if >1 in a hub, and may also include removable microSD cards (if any still be). On PCs, the list will include any mounted (mapped on Windows) device. This app special-cases Android "Internal storage" as "PHONE" (formerly "SHARED"), and treats home ("PC") and root ("ROOT") specially on PCs. Each drive name is truncated at maxnamelen to avoid blowing up GUI. UPDATE: storage row is now hscrolled, so we don't need to trunc much. SUBTLE: volume.getDirectory() was None after USB unmount: don't chain volume.getDirectory().getAbsolutePath(), else craches app. ALSO SUBTLE: the calls used on Windows can pause the chooser open for inaccessible network drives and others; meh - ANR is unlikely (this was addressed for network drives in 1.1.0: see update below). SEE ALSO: Androids 10 and earlier differ in their behavior around drive mount and unmount times (and are arguably buggy!); see the Main tab's folder-chooser dialog docs above for more info. Androids 11+ may hang until the drive is mounted too; a user issue for now. UPDATE [1.1.0]: this hang was resolved in storage_path_app_specific(). ---- WINDOWS UPDATE [1.1.0]: (temp) on Windows, skip the drive-name query for mapped network ('remote') type drives, because it can hang for seconds if the drive is inaccessible. Per rumors on the web, win32wnet.WNetGetConnection() might fetch a network-drive name (in form '\\server\share') sans lag, but '(Remote)' may be as good: this is about only networks mapped to drive letters. ---- ALL UPDATE [1.1.0]: the prior update's mod was backed out, and replaced with thread-based timeout code here, which was also added at later calls to os.listdir() in chooser open, chooser storage-tap reset, and Main action verify (see try_listdir_nohang()). The timeout code here uses try_any_nohang(), and covers all drive-name fetches, including chooser opens and Main-action confirmation dialogs. Timeouts were also added for one Android name call, preemptively. This is a more global/inclusive solution. Forcing '(Remote)' here helped in this one case but inaccessible network drives can still hang in many other contexts, and this is not limited to Windows (macOS hung on dropped network drives for ~5 seconds, and Windows for ~15), nor network drives (Android has no notion of mounted network drives, but choosers were known to hang prior to USB-mount completion; this was resolved with a timeout in storage_path_app_specific()). On Windows, also tweaked the logic here to better handle network drives: use WNetGetConnection() to fetch and use \\server\share UNC path instead of drive letter, because any click in the chooser morphs the path into this anyhow (due to a change to os.path.realpath() in Python 3.8+ used by Kivy, which may have also broke '..' to UNC root). GetVolumeInformation() still used for Windows name, because we're doing timeouts. An tested router drive is named 'T_Drive' with pathname '\\readyshare\T_drive' (not '(Remote)' and 'Z:\'). On all PC platforms, network drives are usable in the app iff they are mounted on Unix or mapped to drive letters on Windows. macOS Finder mounts at '/Volumes/T_drive'; Linux may mount at '/media/me' or '/mnt'. ---- LINUX UPDATE [1.1.0]+ dec23: unlabeled removables - fix plus docs. Change Linux mount-output match+extract so unlabeled drives appear in folder chooser. On Ubuntu 22: no '[label]' at end, and mounts at '/media/me/xxxx-xxxx' UUID. Example: an SDD (labeled T7) and camera (unlabeled) attached by USB cable: Result for both (works for none, one, or both): [('T7', '/media/me/T7'), ('9C33-6BBD', '/media/me/9C33-6BBD')] Mount output: [~$ mount -l | grep "/media/$USER\|/mnt"] /dev/sdb1 on /media/me/T7 type exfat (rw,nosuid,nodev,relatime, uid=1000,gid=1000,fmask=0022,dmask=0022,iocharset=utf8,errors=remount-ro, uhelper=udisks2) [T7] /dev/sda1 on /media/me/9C33-6BBD type exfat (rw,nosuid,nodev,relatime, uid=1000,gid=1000,fmask=0022,dmask=0022,iocharset=utf8,errors=remount-ro, uhelper=udisks2) # see also later C: mount below Prior [scraper = re.compile('^.*? on (.*?) type .*? \(.*?\) \[(.*?)\]\n')] worked for labeled drives, but failed to match unlabeleds (e.g., camera). User can always nav down from ROOT, but folder chooser is more friendly. On [Win, macOS, Andr] name = ['(Removable)', 'Untitled', 'MATSHITA USB Drive']; app makes up a name on Windows, Android auto makes up a name from device type. ---- LINUX UPDATE [1.1.0]+ (feb24): Windows WSL2 Linux mounts a bunch of spurious crap in /mnt; tighten up the parsing here to avoid it, as well as similar on other distros. Caveat: removables still only noticed at WSL2 startup, and some devs never mount. With a T7 SSD and an unlabeled camera both attached by USB: On dual-boot (standalone) Ubuntu: # camera attached => shows up in chooser as "xxxx-xxxx" (and path /media/me/xxxx-xxxx) # t7 ssd attached => shows up as "T7" (and path /media/me/T7) # windows c: manually mounted (properly) => shows up as "OS" (and path /media/me/C) ~$ mount -l | grep "/media/$USER\|/mnt" /dev/nvme0n1p3 on /media/me/C type fuseblk (ro,relatime,user_id=0,group_id=0,allow_other,blksize=4096) [OS] /dev/sda1 on /media/me/T7 type exfat (rw,nosuid,nodev,relatime,uid=1000,gid=1000,fmask=0022,dmask=0022,iocharset=utf8,errors=remount-ro,uhelper=udisks2) [T7] /dev/sdb1 on /media/me/9C33-6BBD type exfat (rw,nosuid,nodev,relatime,uid=1000,gid=1000,fmask=0022,dmask=0022,iocharset=utf8,errors=remount-ro,uhelper=udisks2) ~$ ~$ mount -l | grep "/media" <same as above> ~$ ~$ mount -l | grep "/mnt" ~$ On WSL2 Ubuntu (after "wsl --shutdown" + restart, while camera visible in Windows) # camera not mounted, mia in app (but "(Removable)" (D:\) in Windows app) # C: (win sys) and E: (T7) => "c" (/mnt/c) and "e" (/mnt/e); no "T7" label mia # bad "wsl", "wslg", "distro", "versions.txt", "doc" (= ROOT) tabs in chooser pre fix # bad "doc" from "portal" after open gedit ~$ mount -l | grep "/media/$USER\|/mnt" none on /mnt/wsl type tmpfs (rw,relatime) none on /mnt/wslg type tmpfs (rw,relatime) /dev/sdc on /mnt/wslg/distro type ext4 (ro,relatime,discard,errors=remount-ro,data=ordered) none on /mnt/wslg/versions.txt type overlay (rw,relatime,lowerdir=/systemvhd,upperdir=/system/rw/upper,workdir=/system/rw/work) none on /mnt/wslg/doc type overlay (rw,relatime,lowerdir=/systemvhd,upperdir=/system/rw/upper,workdir=/system/rw/work) C:\ on /mnt/c type 9p (rw,noatime,dirsync,aname=drvfs;path=C:\;uid=1000;gid=1000;symlinkroot=/mnt/,mmap,access=client,msize=65536,trans=fd,rfd=5,wfd=5) E:\ on /mnt/e type 9p (rw,noatime,dirsync,aname=drvfs;path=E:\;uid=1000;gid=1000;symlinkroot=/mnt/,mmap,access=client,msize=65536,trans=fd,rfd=5,wfd=5) portal on /mnt/wsl/.../doc ... ~$ ~$ mount | grep "/media" ~$ mount | grep "media" ~$ ~$ mount | grep "/mnt" <same as above> To improve mount parsing, drop items for which word #1 is "none", and skip "distro" on WSL2 (only) because it's identical to ROOT ('/') already shown. Prior [scraper = re.compile(r'^.*? on (.*?) type .*? \(.*?\)(?: \[(.*?)\])?\n')] shows unlabeled drives by serial#, but opens a pandora's box now partly closed. This doesn't hurt standalone Ubuntu Linux, and may or may not improve some other contexts; alas, Linux is TOO flexible for its users' good at times... TBD: should the regex allow mounts in /media in addition to /media/$USER? No distro is known to use this, Ununtu auoto-mounts into the latter by def, there have been no user reports, and the app mount requirement is documented. But testing has been limited to date (just ubuntu and wsl); and it's Linux. UPDATE: DONE; all /media (really, /media*, including /media_rw) now checked for mounted drives; this should be more inclusive and harmless; but it's Linux. ---- LINUX UPDATE [1.1.0]+ (feb24):: alas, opening gedit adds yet another junk /mnt mount: "doc" from source "portal" (WSL2 only: mounts to /run elsewhere). For better or worse, mounts clearly have multiple non-drive roles, so the rules here were changed again per their Tech Notes description: ''' Mounts: the app's parsing of Linux mount command output was tightened up to discard non-disk interlopers in the app's folder-chooser dialog. Specifically: mounts are now recognized in both /media* and /mnt*, but only if their source is /dev* (a physical device) or a Windows drive letter pattern like C:\ (used in WSL2). Any other mounts are accessible by navigating from the ROOT tab in the chooser. Before this mod, odd WSL2 mounts in /mnt created bogus storage-root tabs in the dialog. This included a "distro" that was identical to ROOT (/); multiple items nested in a "wsl" root; a "version.txt" that wasn't a folder at all; and a "doc" from source "portal" that showed up after launching gedit. All cropped up in /mnt, but this folder cannot be simply skipped: unlike Ubuntu on stand-alone Linux, this is where WSL2 auto-mounts Windows and removable drives (when it mounts them at all: see the next section). The new mount parser drops the WSL2 junk, but retains true storage roots mounted at conventional locations on all Linuxes. It errs on the side of being conservative, because ROOT provides access to the entire filesystem, and mounts have numerous roles. Linux flexibility is both asset and liability. ''' TBD: it might also work to drop the /mnt or /media requirement now that we require source = /dev (or X:\). This would recognize mounts in /home, etc. This app is hesitant to do so, because Linux is wildly variable; norms should be encouraged; testing each distro requires a Linux wipe+install; and there hasn't been a single Linux user feedback in close to a year. """ """ ANDROID: never-finished alt: UsbInfo = autoclass('android.hardware.usb.UsbDevice') trace(UsbInfo) UsbInst = UsbInfo #() usb_uuid = UsbInst.getSerialNumber() use_name = UsbInst.getProductName() trace('USB:', usb_uuid) trace('USB:', usb_name) """ """ In Java: StorageManager storage = (StorageManager) getSystemService(STORAGE_SERVICE); if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) { List<StorageVolume> volumes = storage.getStorageVolumes(); for (StorageVolume volume :volumes) { Log.d("STORAGE", "Device name:" + volume.getDescription(this)); } } -------------------------------------------------------------------- """ # if not RunningOnAndroid: # don't nest: there be significant PC code here if RunningOnMacOS: #------------------------------------------------------------------- # this feels wrong (custom mounts?), but works so far... # grep for removable drives auto-mounted in /Volumes/xxx # this is where removable drives are auto-mounted by macOS # # drive mounts are something macOS arguably does better: # it combines determinism of Windows' drive letters with # the generality of Linux's filesystem hierarchy; drives # are an odd special case in Windows, and at unpredictable # locations in Linux; neither is true in macOS #------------------------------------------------------------------- return [(name, '/Volumes/' + name) # 'M.HD' = ROOT for name in os.listdir('/Volumes') # mounted here if name not in ['Macintosh HD', 'Recovery']] # complete? elif RunningOnWindows: #------------------------------------------------------------------- # this also seems bad, and was replaced by pywin32 code below... # # possroots = [chr(ord('A') + i) + ':' for i in range(26)] # 'A:'...'Z:' # usedroots = [root for root in possroots if osexists(root)] # return [(getnamefromshellcommand?(root), root) for root in usedroots] # # do the convoluted and proprietary Windows-API thing via pywin32 # win32 api requires pywin32 (or ctypes) - adds a dependency, but not much # # ---- # [1.1.0]+ (feb24) note only => PUNT # unlabeled drives are displayed as "(Removable)" or "(Remote)" # which may be subpar if > one each; this could instead use the # serial number returned in GetVolumeInformation()[3]; # # otoh: serial number in Windows is a simple long int, not the # nice Linux xxxx-xxxx uid (though it could be: see below); and # is a meaningless number really better than "(Removable)" when the # Windows drive letter is shown on tap? multiple unlabeleds seems # rare (and meaningless serial numbers won't help either way), and # macOS's "Untitled" mount label in /Volumes" does no better; # # it seems beter to assume that users will label their drives; # cameras that don't support this, will be the only "(Removable)". # bonus - to xlate a 32-bit long to ax xxxx-xxxx hex id: # # >>> x = 2 ** 32 - 1 # >>> y = ('%X' % x) # >>> y[:4]+ '-' +y[4:] # 'FFFF-FFFF' #------------------------------------------------------------------- # get drive roots, network paths [after 'pip3 install pywin32' on windows] # [1.1.0]+ (nov23) moved to top of script: avoid abort if temp folder deleted # import win32api, win32file, win32wnet # 'C:\\\x00D:\\\x00Z:\\\x00' => ['C:\\', 'D:\\', 'Z:\\'] # allroots = win32api.GetLogicalDriveStrings().split('\0')[:-1] # assoc root with type, so can skip network drive names (now moot) [1.1.0] # volroots = [root for root in allroots # if win32file.GetDriveType(root) in usetypes] # drive types we care about - iff mapped to letter usetypes = (win32file.DRIVE_REMOVABLE, # usb ssd, flash, microsd, etc win32file.DRIVE_FIXED, # system drive, other win32file.DRIVE_CDROM, # bdr or cdrom, verified win32file.DRIVE_REMOTE) # network or vm host # filter by type [t7 usb ssd reports as fixed, weirdly] volroots = [] for rootpath in allroots: roottype = win32file.GetDriveType(rootpath) if roottype in usetypes: volroots.append((rootpath, roottype)) trace('volroots:', volroots) # get volume names [microSD reports 2 roots, one fails GVI()] namesandpaths = [] for (rootpath, roottype) in volroots: # use timouts to avoid lags for inaccessible network drives [1.1.0] def getnameandpath(rootpath, roottype): name = win32api.GetVolumeInformation(rootpath)[0] # hangware if roottype != win32file.DRIVE_REMOTE: path = rootpath else: # use unc for path, not letter path = win32wnet.WNetGetConnection(rootpath.rstrip('\\')) return (name, path) name_path = self.try_any_nohang( getnameandpath, rootpath, roottype, onfail=(None, None)) # skip drive if name=None: exc or timeput; else rootpath=unc for net name, rootpath = name_path if name != None: if name == '': vtype = roottype # was win32file.GetDriveType() [1.1.0] makenames = {win32file.DRIVE_REMOVABLE: '(Removable)', win32file.DRIVE_FIXED: '(Fixed)', win32file.DRIVE_CDROM: '(Optical)', win32file.DRIVE_REMOTE: '(Remote)'} name = makenames.get(vtype, '?') name = name[:maxnamelen] namesandpaths.append((name, rootpath)) # see also confirm: strip parens! trace('namesandpaths:', namesandpaths) # set+discard ROOT=system drive [may be named 'OS', 'WINDOWS', ?] magicvar = 'SYSTEMDRIVE' if magicvar in os.environ: # we got ROOT self.windows_os_root = os.environ[magicvar] if not self.windows_os_root.endswith('\\'): self.windows_os_root += '\\' # used by storage_path_root # don't list system drive > once, regardless of name namesandpaths = [np for np in namesandpaths if np[1] != self.windows_os_root] else: # go fish: probably never required, but... osnames = [np for np in namesandpaths if np[0].upper() == 'OS'] winames = [np for np in namesandpaths if np[0].upper() == 'WINDOWS'] if len(osnames) == 1: self.windows_os_root = osnames[0][1] # use OS first, if just 1 namesandpaths.remove(osnames[0]) elif len(winames) == 1: self.windows_os_root = winames[0][1] # else try WINDOWS, if just 1 namesandpaths.remove(winames[0]) else: self.windows_os_root = 'C:\\' # punt: too convoluted to try trace('root+namesandpaths:', self.windows_os_root, namesandpaths) return namesandpaths elif RunningOnLinux: # linux (and wsl2) only, not android #------------------------------------------------------------------- # yep, there's a command line for that... but it's brittle... # parse out the salient bits for chooser's removable-storage tabs # # includes native linux, wsl on windows, lx virtual machines # result+chooser includes 'os' = windows drive if dual boot # note: removables may be in os: drive=/media/me/yyy, os=/media/me # # 'mount -l' adds [label], '/media/$USER' is where USB appears; # command output and re groups: # # /dev/xxx on /media/me/yyy type zzz (xx,xx,xx,xx) [T7SSD]\n' # 11111111 2222222222222 33333 # # assume mount commands don't hang [1.1.0] # # UPDATE [1.1.0]: grep for mounts in _both_ /media/$USER (original, # and where system-mounted things like USB drives and the Windows # system drive show up), and /mnt (new, and where some Linux docs # suggest other manually mounted external storage like network # drives should be hosted). /media can be used for all, of course, # and mounts can really be anywhere; this is opinionated+variable. # # UPDATE [1.1.0]+ dec23: match unlabeled drives too by generalizing # the mount regex to make label optional, and extract UUID for name # from end of path; see docstring at top of this method for more info. # Prior: re.compile('^.*? on (.*?) type .*? \(.*?\) \[(.*?)\]\n') # # UPDATE [1.1.0]+ (feb24): discard spurious mounts on Windows WSL2 # (and other) Linux - ignore if source (word #1) is not "/dev*" or # "X:\", or name via path tail is "distro" on WSL2 (= ROOT); see above. # ALSO: grep all /media/* (users), not just /media/$USER (auto-mounts). # Formerly: # shellout = os.popen('mount -l | grep "/media/$USER\|/mnt"').readlines() # scraper = re.compile(r'^.*? on (.*?) type .*? \(.*?\)(?: \[(.*?)\])?\n') # scraper = re.compile(r'^(.*?) on (.*?) type .*? \(.*?\)(?: \[(.*?)\])?\n') # # UPDATE [1.2.0]: also check that parsed mount points are folders; # likely moot, but better to be defensive/cautious on this stuff. #------------------------------------------------------------------- shellout = os.popen('mount -l | grep "/media\|/mnt"').readlines() scraper = re.compile(r'^(.*?) on (.+?) type .*? \(.*?\)(?: \[(.*?)\])?\n') wslmount = re.compile(r'[A-Z]:\\.*') namesandpaths = [] for line in shellout: match = scraper.match(line) if match: msource, mpath, mlabel = match.groups() # [1.1.0]+ (feb24) skip non-drives if not (msource.startswith('/dev') or wslmount.match(msource)): continue # [1.2.0] skip non-folders # omit in choosers if not self.try_isdir_nohang(mpath): # exc, timeout, false continue if mlabel != None: # labeled: use [xxx] name = mlabel or '(Unlabeled)' # unless it's empty else: # unlabeled: try path name = mpath.split('/')[-1] # use end of path if RunningOnWSL2 and name == 'distro': # [1.1.0]+ (feb24) continue # skip distro==ROOT name = name[:maxnamelen] namesandpaths.append((name, mpath)) # label|end, path trace(namesandpaths) return namesandpaths #----------------------------------------------------------------------- # original Android code: refetch on each call (state may change) # do the convoluted and proprietary android-api thing via pyjnius # # [1.1.0] assume only getDescription() can hang (plus later system calls); # Android hangs are intermittent+unrepeatable, but timeouts are harmless; # the self.try_any_nohang(F) returns returns None if F hangs or raises exc; # also in 1.1.0, skip drive if exc or timeout, don't label as '(Removable)'; # # UPDATE [1.1.0]: per tracing, the run1 hang doesn't happen here - it was # finally made repeatable and isolated to the app-specific code above, in # getExternalFilesDir(), where it was fixed with a timeou. But keep the # timeout here: it's harmless, and being proactive is shrewd on Android... #----------------------------------------------------------------------- context = self.get_android_context() storage = context.getSystemService(context.STORAGE_SERVICE) volumes = storage.getStorageVolumes() namesandpaths = [] for i in range(volumes.size()): # use java iterator volume = volumes.get(i) if self.android_version() < 11: # through 10/Q, api 29 pypath = volume.getPath() # why did this go away? (bugs?) if not pypath: # aosp MediaStore bigotry? continue else: jvfile = volume.getDirectory() # for android 11+ if jvfile == None: # no getDirectory() in 10 continue # None/null post unmount pypath = jvfile.getAbsolutePath() """TEMP trace(volume.getUuid(), # xxxx-xxxx or None volume.isPrimary(), # False for usb volume.isRemovable(), # True for usb volume.getDescription(context), # name: T7, Internal storage volume.getDirectory() # java.io.file, None post unmount if self.android_version() >= 11 else volume.getPath(), # a simple path string pypath, # posix file-path nirvana sep=' -- ') TEMP""" if (not volume.isPrimary()) and volume.isRemovable(): # get name, else None if exc or hang [1.1.0] name = self.try_any_nohang(volume.getDescription, context) if name != None: # tbd: skip if /dev/null=mounting on android 10- # this won't help for android 11+: access fails name = name or '(Removable)'[:maxnamelen] # fake if name is ''; see confirm namesandpaths.append((name, pypath)) # skip if exc or timeout trace(namesandpaths) return namesandpaths def storage_path_documents(self): """ -------------------------------------------------------------------- [/storage/emulated/0/Documents] Generally accessible and persistent. Used for logfiles on Android, but not added to Main chooser (or called out in confirmation dialogs [1.1.0]): it's a sub of shared. Available in plyer too (though not in android), but it's simple enough to use the Java-equivalent here explicitly. [1.1.0]+ (feb24) ~/Documents folder is now created if needed in init_logfile_folder() (e.g., for WSL2 Linux). This could be done here instead, but docs folder is used only for logfiles in this app today, and hence isn't needed until Logs-tab open or Main-tab action. If it's ever added to folder choosers, move the create code here instead (and ensure timing is okay). -------------------------------------------------------------------- """ if not RunningOnAndroid: # same on all 3 PCs return osjoin(os.path.expanduser('~'), 'Documents') # original Android code Environment = autoclass('android.os.Environment') docsname = Environment.DIRECTORY_DOCUMENTS documents = Environment.getExternalStoragePublicDirectory(docsname) documents = documents.getAbsolutePath() if not osexists(documents): os.mkdir(documents) return documents def storage_path_root(self): """ -------------------------------------------------------------------- [root is /, but android api returns /system or /storage] Generally useful only on rooted devices - the chooser adds a ROOT button iff Config button enables it. Root is /, which is not accessible on unrooted phones, but the api calls here return /system (the os mount point) or /storage (storage sources, which may be more useful). Allow ROOT to be turned on/of via Config, but show /storage if / fails, and avoid uncaught excs by setting dialog.rootpath in kivy FileChooser to prevent ".." navigation altogether. UPDATE: Environment.getStorageDirectory() is devices mount point, which reports as /storage on the test device with All Files Access, and gives access to SAF providers (including Termux's app-private). UPDATE: os.listdir('/storage') fails; fallback to useless /system (and consider punting ROOT in full: may require su for listdirs). -------------------------------------------------------------------- """ if not RunningOnAndroid: # neither of these span all drives directly, but useful; # Windows assumes already set by names_and_paths: call first if RunningOnWindows: # from env var or volume names return self.windows_os_root # probably c:\, but not always else: # it's macOS or Linux or ? return '/' # system drive root, see also os=/media # original Android code Environment = autoclass('android.os.Environment') roots = ['/', # canonical Environment.getStorageDirectory().getAbsolutePath(), # /storage Environment.getRootDirectory().getAbsolutePath(), # /system ] for root in roots: try: os.listdir(root) # assume won't hang [1.1.0] except: pass else: return root return None # all failed #======================================================================= # SETTINGS PERSISTENCE #======================================================================= def init_setting_defaults(self): """ -------------------------------------------------------------------- Paths and Config-tab settings: map .kv-file GUI ids => field defaults. Now a method (not class attributes), to use self in setting values. Main-tab paths: Loaded on start, auto-saved on Main runs, not saved/reset on Config btns Config-tab configs: Loaded on start, manually saved/reset on Config btns, not auto-saved on Main runs For Main-tab paths, fail in default kivy font: ⇦ ← ⇐ ⬅; go DejaVuSans for these two fields in the .kv file (better appearance anyhow). NOTE: because these defaults are reloaded into the GUI on Config-tab's 'Restore' and fetched by code from the GUI, all presets tweaks must happen here only; else a Restore may mod a setting in a way triggers erroneous actions, even if the corresponding Config-tab widget is disabled. -------------------------------------------------------------------- """ self.config_ids_Main = dict( # main-tab paths, auto-save on run frompath = '⇦ Tap to select FROM, or enter here', topath = '⇦ Tap to select TO, or enter here', ) self.config_ids_Config = dict( # config-tab settings, manual save/restore # toggles backupscheckbox = True, skipcruftscheckbox = True, runactionasservice = RunningOnAndroid and self.android_version() >= 9, appfolderselections = RunningOnAndroid, # moot on PCs rootfolderselections = not RunningOnAndroid, # start True on PCs keepscreenon = True, # applies to android+pcs [1.1.0] # inputs maxnumbackups = 25, maxnumlogfiles = 30, maxlogstailsize = 10 * 1024, # was 32k: save graphics mem # appearance logsbackgroundcolor = '#000038', logsforegroundcolor = 'white', globalfontsize = FONT_SIZE_DEFAULT, # '15sp' to abs: set_font_size() mainchoosericon = ['down'], mainchooserlist = ['normal'], # last two are one selection # defunct #logfilepathname = 'Select or enter...' # always log, same folder #logfilescheckbox = True, #fixfilenamescheckbox = True, # moved to Main action ) self.config_ids_All = self.config_ids_Main.copy() self.config_ids_All.update(self.config_ids_Config) # i.e., Main + Config def load_settings_from_file(self): """ Load ALL settings from dict in app-private pickle file. """ apppriv_path = self.storage_path_app_private() or '' settings_path = osjoin(apppriv_path, SETTINGS_FILE) if not osexists(settings_path): settings = self.config_ids_All.copy() # first run: defaults else: try: settings_file = open(settings_path, 'rb') settings = pickle.load(settings_file) settings_file.close() except: settings = self.config_ids_All.copy() self.info_message('Cannot load settings: please report.', usetoast=True) return settings def save_settings_to_file(self, settings): """ Save ALL settings to dict in app-private pickle file. """ try: apppriv_path = self.storage_path_app_private() or '' settings_path = osjoin(apppriv_path, SETTINGS_FILE) settings_file = open(settings_path, 'wb') pickle.dump(settings, settings_file) settings_file.close() except: self.info_message('Cannot save settings: please report.', usetoast=True) def update_GUI_from_settings(self, settings): """ Copy ALL loaded settings into the GUI's fields. """ for (id, value) in settings.items(): try: if type(value) == str: self.ids[id].text = settings[id] # str text elif type(value) == bool: self.ids[id].active = settings[id] # checkbox elif type(value) == int: self.ids[id].text = '{:,}'.format(settings[id]) # int text elif type(value) == list: self.ids[id].state = settings[id][0] # radio btn except KeyError: pass # old app version + new settings file - skip new key [1.1.0] def update_settings_from_GUI(self, settings, config_ids): """ Update SOME settings in-place from GUI fields: config_ids=Main or Config. """ for (id, default) in config_ids.items(): if type(default) == str: settings[id] = self.ids[id].text elif type(default) == bool: settings[id] = self.ids[id].active elif type(default) == int: settings[id] = int(self.ids[id].text.replace(',', '')) elif type(default) == list: settings[id] = [self.ids[id].state] def reset_persisted_settings_Config(self): """ Restore all CONFIG settings to original "factory" defaults. This resets Config-tab settings only, NOT Main-tab paths; the latter would be a suprise, as paths are on another tab. TBD: should this also write them to persistence file now? """ # gui fields for (id, default) in self.config_ids_Config.items(): if type(default) == str: self.ids[id].text = default elif type(default) == bool: self.ids[id].active = default elif type(default) == int: self.ids[id].text = '{:,}'.format(default) elif type(default) == list: self.ids[id].state = default[0] # internal copy self.settings.update(self.config_ids_Config) # Main paths never reset # apply Config's font size now, else user must tap Apply after # Restore, even though the Config tab has been reset to the default; # this is grayer than startup (user edits aren't auto applied either), # but it's arguably user friendlier to make the GUI match the restore self.set_font_size(self.ids.globalfontsize.text) def load_persisted_settings(self): """ -------------------------------------------------------------------- On startup, fetch saved configs+paths, and use them to intialize the GUI's fields. This includes all settings on the Config tab, and all selectable paths (i.e., FROM and TO). Settings start with the defaults above before any saves. Paths are saved automatically on each Main-tab action run, and configs are saved manually on each Config-tab save button path tap. The Config-tab restore button restores only configs, not paths, because paths are on a different tab. The Config tab includes both run and non-run settings, but auto-saves are too presumptive. Stored in app-private storage (not app install), in a pickle-file dict, where the .kv file's IDs are used as dict keys in settings. App-private is app's data folder, can't be seen by explorers or other apps, and goes away on app uninstall unless user toggles the save option in uninstall popup (per manifest fragile setting). UPDATE [1.1.0] copy any new items in defaults to settings loaded from file, so that they will be set in GUI and added to file on later saves. Else first value comes from .kv, not defaults here, which is fairly harmless, but makes for redundant code/logic. Note that this makes new versions of the app backward compatible with existing settings files, but not vice vera: running an old version of the app with a new settings file that has new keys will fail when updating the GUI; catch excs in GUI load for this. -------------------------------------------------------------------- """ # Set up presets once now self.init_setting_defaults() # Internal copy, kept for later saves self.settings = self.load_settings_from_file() # Update loaded settings for anything new in defaults [1.1.0] for key in self.config_ids_All: if key not in self.settings: self.settings[key] = self.config_ids_All[key] # Copy all loaded settings into the GUI's fields self.update_GUI_from_settings(self.settings) # Extras call moved to App.on_start() to make it more explicit def save_persisted_settings_Main(self): """ On Main-tab action run: update Main-tab paths in persistence file. Nit: it's inefficient to save all each time, but the set is small. """ # Get Main settings from the GUI's fields self.update_settings_from_GUI(self.settings, self.config_ids_Main) # Save full settings dict to pickle file self.save_settings_to_file(self.settings) def save_persisted_settings_Config(self): """ On Config-tab Save: update Config-tab settings in persistence file. maxbkps is a mergeall config-module edit in app-install subfolder, and subtly also requires updating the imported module: see startup. """ # Get Config settings from the GUI's fields self.update_settings_from_GUI(self.settings, self.config_ids_Config) # Save full settings dict to pickle file self.save_settings_to_file(self.settings) def startup_gui_and_config_tweaks(self): """ Assorted mods to gui and configs for platform diffs/etc, post load of persisted setings. Now called explicitly from App.on_start(), after load_persisted_settings has initialized or loaded settings. """ # Mod filename fixer, now a Main tab action, not a config # Update: PUNT - this looks odd, wasn't re-disabled on action exit, # and it's unknown if non-Windows filesystems on Windows need NAME if RunningOnWindows: # Nonportable names not possible on Windows or Android shared """ self.ids.namebutton.disabled = True self.ids.namelabel.disabled = True """ pass """ TBD: Android appspec allows nonportables, but not advised; init only elif RunningOnAndroid and not self.ids['frompath'].text.startswith(self.storage_path_app_specific()): # yes, but need to reset on path changes - even manual edits; punt """ # On android 8.x (only), foregroundservice works but broadcastreceiver does # not, for reason that 8's market share no longer justifies uncovering; punt! def disable_android_service_gui(): # must do in defaults: self.ids.runactionasservice.active = False self.ids.runactionasservice.disabled = True self.ids.runactionasservicelabel.disabled = True if RunningOnAndroid: if self.android_version() < 9: # i.e., android 8~8.1, api 26~27 disable_android_service_gui() else: # on 9+, resume in-progress state if kill+restart while service runs # UPDATE: PUNT - deprecated and inaccurate; see the method for docs # self.check_for_running_service() pass else: # PCs - foreground service and notifications moot disable_android_service_gui() if not RunningOnAndroid: # # no locked-down/auto-removed app storage on pcs # must do in defaults: self.ids.appfolderselections.active = False # self.ids.appfolderselections.disabled = True self.ids.appfolderselectionslabel.disabled = True if not RunningOnAndroid: # # scrolling is too slow on PCs (only) # scrollviews = ('helptextscroll', 'abouttextscroll', 'picklogscroll', 'logfilescroll') for scroll in scrollviews: if RunningOnWindows or RunningOnLinux: self.ids[scroll].scroll_wheel_distance = 20 self.ids[scroll].smooth_scroll_end = 20 else: self.ids[scroll].scroll_wheel_distance = 10 # macos is special self.ids[scroll].smooth_scroll_end = 60 if not RunningOnAndroid: # # kill the BIZARRO persistent orange circles on double clicks (kivy bug!) # seen on windows+macos; not documented, and wth hasn't this been fixed? # Config.set('input', 'mouse', 'mouse,multitouch_on_demand') if RunningOnWindows or RunningOnLinux: # -------------------------------------------------------------------- # window size # # The default size opens too small on these platforms only; # doing this earlier at imports does not avoid resize twitch # # no effect: Config.set('graphics', 'width', 1000) # no effect: Config.set('graphics', 'height', 900) # Config.write() may help, but applies to all apps always # # ---- # [1.1.0]+ (sep23): use dp() density-scaled pixels here too, else # preset size was okay on a low-res (2k) test machine, but too small # to use on a high-res (4k) screen sans a manual resize on each open; # this mod was released in the Windows+Linux executabes _only_ (not # Android or macOS: unused); on Windows, the new dp() setting here # looks same as 'original' setting on low-res display (for screenshots); # # note: _not_ setting Windows.size (always, or on runs 2+) doesn't # help - Windows does not remember/restore the prior run's size, so # the window will always open at the too-small-on-low-res default; # # also: dp() seems iffy on Linux - add a fudge factor else too small # [this is risky: has been tested on just one linux device to date]; # on linux test dev, dp() does nothing... which means scale factor # is 1... which is distubing, because dp() matters for same 1920x1200 # display on Windows in a dual-boot setup; does scaling really work?; # # linux has lotsa problems: default fonts start painfully small too # (this was tweaked in [1.1.0]+ (oct23) above), and it's unknown if # the linux executable works beyond test devs (the [1.1.0]+ (oct23) # rebuild on ubuntu22 fixes a lib-skew issue in u20; others likely); # note: RunningOnLinux constant means linux only, and not android; #-------------------------------------------------------------------- initial_center = Window.center # save for recenter next [1.1.0]+ (oct-24-23) lx = 150 if RunningOnLinux else 0 # was 100 (oct23) Window.size = (kivy.metrics.dp(748+lx), kivy.metrics.dp(612+lx)) # (w, h), [1.1.0]+ #Window.size = (748, 612) # different: too small on low-res dell #Window.size = (1120, 920) # original: too small on high-res yoga if RunningOnWindows: # not Linux: see ahead #-------------------------------------------------------------------- # window position # # [1.1.0]+ (oct-22-23): Windows' initial window position is lousy: # users must move the window immediately to see tha bottom part # of the GUI which comes up offscreen. This has been seen on both # 2K and 4K displays. E.g., default = [300,560] on 1920x1200, # but 560+scaled(612) is offscreen (scaled size is [1122,918] # after code below, and 560+918=1418 > 1200 screen height). # # Force startup near upper left corner here, and scale for screen # density, but don't place any furter down/right than the default. # E.g., on 1920x1200, scaled height 612 = 918, scaled pos 85 = 127, # and 127+918=1045: onscreen. Scaling will be higher on 4k and # lower on 1K (e.g., 85 + 612 = 697 < any usable display's height). # # Position can be set before size (ahead) and doesn't impact # the splashscreen here. Note: env var SDL_VIDEO_WINDOW_POS # is supposed to set position, but doesn't (anymore? on Win?). # This also isn't an issue on macOS, which centers windows well, # nor on Linux, where there's ample space below a smaller window. # # Subtly: this may be a timing issue in Kivy. Per testing, it # appears that window position is set by Kivy just _once_ and for # the _default_ size. This explains why, sans the pos/size sets # here, the spashscreen and window are the same size on Windows, # and both center well (and why macOS centers well with default # size). With the required size setting below, the splashscreen # stays at the original size for default window. This is arguably # a bug in kivy: setting size should auto update position auto, # especially before the window is opened. # # With the pos/size sets here, the splashscreen is smaller than # the window and slighly to the right/down (because it's centered), # but the skew is trivial. Running the size set alone and earlier # (e.g., at top of script) might auto center, but could also make # the splashscreen huge (TBD). In any event, the window must be # resized, because the default is too small to be usable on Windows. # Kivy also does not allow Windows to auto-position new windows # (which also pushes content off-screen eventually in tkinter). # # ==== # UPDATE [1.1.0]+ (oct-24-23): this now _recenters_ the window on # the display, and _after_ it has been resized, instead of just # positioning it at scaled(85, 85) pixels before resizing. # This is marginally better aesthetics (and avoids bringing up # the window too far away from the splash screen), though the window # is slightly lower than the 85px original (by single-digit pixels), # and the window is slightly higher than center (by the size of the # Windows taskbar - the app's OpenGL window itself is centered, # but the Windows taskbar at the top grabs more space above it). # # There is no known direct way in kivy to get physical display size # in pixels for manual centering calcs. Instead, move position # per new post-resize Window.center point, but not beyond the # (0,0) point at top left (else may be off-screen again?), and not # so high that Windows taskbar off-screen (hopefully: a guess). The # Window.center gives a pixels value portably, and can be use to # do positions indirecty. Doing only resize in App.build() or at top # of script had no effect (see tries ahead): kivy doesn't auto-recenter # on size mods, and probably shouldn't, _except_ before window opened. # As is, kivy auto-centers just once, and for a default (small) size # (and this drama is all because kivy default size on Windows sucked). # Skip on Linux: position okay, and Wayland may disallow repositions? #-------------------------------------------------------------------- """ORIGINAL dflttop, dfltleft = Window.top, Window.left Window.top = min(dflttop, kivy.metrics.dp(85)) Window.left = min(dfltleft, kivy.metrics.dp(85)) """ # recenter instead of pinning to abs location new_center = Window.center # center after resize above diffx = new_center[0] - initial_center[0] # how much did center change? diffy = new_center[1] - initial_center[1] Window.left = max(5, Window.left - diffx) # adjust top-left for change Window.top = max(25, Window.top - diffy) # but not past top-left corner trace('Repos:', initial_center, new_center) # @2k: (400.0, 300.0) (561.0, 459.0) # apply Config's font size now, else user must tap Apply each # run, even though Config has been set to the last size saved; # see also restore-defaults: similarly auto-applies value set; self.set_font_size(self.ids.globalfontsize.text) # Force left-align text in Main tab's path fields (else pseudo-random) # weirdly hard, but halign and scroll_x in .kv had no effect, for reasons tbd # scheduling separately helps, but still fails on 1 of 6 test devs (andr9) # UPDATE: this is related to font size: moving below that setting here # fixed andr9 too; clock scheduling is still required, but just once... def leftalign(field): self.ids[field].cursor = (0, 0) Clock.schedule_once(lambda dt: (leftalign('frompath'), leftalign('topath'))) #Clock.schedule_once(lambda dt: leftalign('frompath')) #Clock.schedule_once(lambda dt: leftalign('topath')) """ now done in class def, so picked up at each instance build if RunningOnMacOS: # [r, g, b, a] for image tinting - too dim on macos only ConfigCheckbox.check_box_color = [1, 3, 5, 4] # else default = [1, 1, 1, 1] """ # [1.1.0] initialize sdl2 screen-stays-on switch from configs; # this applies to both pc screen saver and android screen timeout Window.allow_screensaver = not self.settings['keepscreenon'] # see on_start() def update_mergeall_configs_maxbackups(self): """ -------------------------------------------------------------------- On Main-tab SYNC runs only, apply the Config tab's setting maxnumbackups to mergeall/mergeall_configs.py such that it will be used by mergeall sync runs. This needs no changes in Mergeall's base code (e.g., a new command arg), but must be implemented in ways that vary by mergeall.py run mode: + For processes, both foreground-service (Android) and simple (PC?), it suffices to update the config module's source code, because mergeall runs in its own process and imports the config module from its changed .py anew on each run. + For threads (Android + PC), this must update the imported configs module object, because mergeall runs in the same single app process, in which configs is only ever imported once, even if its source code is changed for processes here. A module reload works too, but setting an attribute in the sudbir module imported at startup suffices. Since we don't know if mergeall will be run as processes, threads, or any mix of these, we must accommodate both run modes here. Threads need only imported-module mods, and processes use only source, but either can be run any time. Because this always updates for the current Config-tab setting, there is no potential for source/setting skew. On app start, the Config tab will be reloaded with the last setting saved. On each SYNC run, the source code and imported module will be updated for whatever is in the Config tab at the time. App startup initially picks up the setting last saved to the source code via import, but SYNCs always use and apply the setting in the Config tab, loaded on app start. Hence, if a Config-tab setting was used for a SYNC but not saved, the latest save will override the source on subsequent SYNCs. That is: module object (for threads) and code (for processes) always agree on SYNCs, and reflect the current value in Config, which may be persisted across runs or not. ---- UPDATE: it gets subtler - this originally reset the max setting in the mergeall_configs module, but backup.py gets the setting with a "from" instead of an "import." Hence, backup's value won't be updated if mergeall_configs is changed here: mergeall imoorts backup which imports mergeall_configs, but backup's "from" gets the setting, not its module, and backup itself will never be imported again by mergeall in the app process. Thus: this must mod backup, not mergeall_configs, else the update won't be noticed when SYNC runs as a thread (!). It's also simple to access backup in sys.modules, which is shared by app and script threads; it must still be imported initially, however, to get its value to detect diffs for both thread and process runs. mergeall_configs' MAXBACKUPS is used nowhere else in mergeall (and has no other app configurables). See also init_mergeall_globals(), which is similarly run on SYNCs to reset global state in some of mergeall's imports, but for same-process threads only (here, we mod for both). For threads ONLY, that imports backup before this is called. Subtle: this need not be called for UNDO, as -backup not used. ---- UPDATE: and it gets worse - resetting MAXBACKUPS in the backup module shared by app and script threads still has no effect, because the setting is a function-argument default in backup: def prunebkpdirs(toroot, maxbackups=MAXBACKUPS): The setting in this is evaluated once at _function creation time_ and the result is attached to the function object. Hence, while changing MAXBACKUPS in the shared module works, it has no impact on this function, which is ultimately what mergeall.py calls. So... as a third rewrite here (!), instead remove the backup and mergeall_configs modules from sys.modules in full, to force imports in the thread to reload from the changed source code. Probably, a change in backup.py and/or a new command arg in mergeall.py for maxbackups is warranted now, but the goal was no Mergeall changes. None of init_mergeall_globals()'s attr mods are functions args. Forcing reloads here makes that function moot for backup.py, but ONLY when the Config-tab's numbackups setting differs from the code on a SYNC, and that function isn't called for process runs. Note: mergeall.py prunes backups only for SYNCs with backups enabled that actually make changes (thereby triggering a new backup folder). Lowering the max setting will have no effect until this happens. By contrast, logfiles are pruned each run. ---- UPDATE+CAVEAT: the change made here to mergeall/mergeall_configs.py in the install folder will be undone if the user clears this app's data with Settings=>Apps=>appname=>Storage=>Clear data. This is officially expected behaviour (though an arguable misfeature in Android!). Since this also wipes/resets all Config-tab user settings, the reset to the .py seems reasonable (and fixes are too extreme). See handle_trial_counter() for a related uninstall-wipe drama. WEIRDLY, this is not an issue for reinstalls: the .py mod here (along with configs and all other files in app-private/install folders) is retained by and survives both an adb streamed reinstall (which is presumably what Play store updates do), and an uninstall+install if the unsintall option to keep app data is checked. Hence, it's JUST Settings' clear-app-data action that resets in full - inconsistently! -------------------------------------------------------------------- """ if 'backup' not in sys.modules: # first script run: import backup module trace('ma=> importing backup') # detect diffs, for threads + processes os.chdir('mergeall') # in app-install (unzip) folder sys.path.append(os.getcwd()) # '.' fails here, use real path import backup # threads run in subfolder, same process os.chdir('..') # services run in separate process sys.path.pop() configbkps = int(self.ids.maxnumbackups.text) # Configs-tab setting mergeallbkps = sys.modules['backup'].MAXBACKUPS # value imported into module by "from" if configbkps != mergeallbkps: # 1) Source code - for processes (and future app runs) trace('ma=> configs code update') try: maconfigspath = osjoin('mergeall', 'mergeall_configs.py') maconfigsfile = open(maconfigspath, 'r', encoding='utf8') maconfigstext = maconfigsfile.read() maconfigsfile.close() replace = 'MAXBACKUPS = %d' % configbkps pattern = '^MAXBACKUPS = [0-9]*' maconfigstext = re.sub(pattern, replace, maconfigstext, 1, re.MULTILINE) maconfigsfile = open(maconfigspath, 'w', encoding='utf8') maconfigsfile.write(maconfigstext) maconfigsfile.close() except: self.info_message('Cannot change max backups.', usetoast=True) # 2) Imported module - for threads trace('ma=> backup module update') # reset mod attr: fails if func arg default #sys.modules['backup'].MAXBACKUPS = configbkps # force import of new code in script thread del sys.modules['backup'] # mergeall => backup => mergeall_configs del sys.modules['mergeall_configs'] # or dict.pop() #======================================================================= # CONFIG AND HELP TABS #======================================================================= def pickcolor(self, onpick, kind): """ On Pick in bg or fg color in Configs tab: open chooser. The chooser's Pick in turn passes color to do_ handler. In detail: onpick is passed in from the Config-tab button and sent to the popup, and is one of the do_x_color below (plus self); it's not called until the popup's on_release. """ picker = ColorPickDialog(onpick=onpick, oncancel=self.dismiss_popup) self._popups.push(Popup(title='Choose %s Color' % kind, content=picker, auto_dismiss=False, # ignore taps outside size_hint=(0.9, 0.9))) # max space self._popups.open() def do_bg_color(self, color, hexcolor, popupcontent): """ Log tab's text color now set automatically via cross-tab ref in the .kv file to Config tab color display. Else Log tab is not reset on defaults restore in Config tab. Config tab colors must be readonly, else app crashes as user types and so must be TextInputNoSelect to avoid lingering handles; blah. """ self.dismiss_popup(popupcontent) self.ids.logsbackgroundcolor.text = hexcolor # Config tab (hex str) #self.logfiletext.background_color = color # Logs tab (rgba list) def do_fg_color(self, color, hexcolor, popupcontent): "Ditto" self.dismiss_popup(popupcontent) self.ids.logsforegroundcolor.text = hexcolor # or tie to 1 prop? -> yes #self.logfiletext.foreground_color = color # too many ways to do it def set_font_size(self, fontsize): """ -------------------------------------------------------------------- On Apply for font size in Config: setting the App instance's dynamic_font_size here automatically changes font size for EVERY wdget in GUI, because Widget font_size is bound to it in the .kv file. Powerful (and arguably scary). Otherwise, GUIs can bind font_size to this property in selected widgets. This size is in absolute pixels as coded, but could append size string with 'dp' or 'sp' to use density and user+density scaling: see kivy.org/doc/stable/api-kivy.metrics.html#kivy.metrics.sp. This seems arbitrary (a relative # is a relative #), but the preset defalt may need to be scaled per device on startup. Kivy's default is '15sp' which maps to '39' pixels on a Note20. ---- UPDATE: use 'sp' pixels to avoid the scaling issues of preset... Downside: this provides less granularity then absolute pixels (1 sp pixel is about 2.56absolute pixels on screens like the Note20's), and could probably specialize preset by platform (TBD). UPDATE: use the fontsize from the GUI as a string and allow input per float filter, instead of assuming an int(). This allows fraction sp pixels to compensate for the granularity. UPDATE: for better granularity, use absolute int pixels in GUI, but set default/start to '15sp' equivalent per kivy.metrics (39 on Note20). This scales per device and user settings on first run, though it won't auto-update for user settings till rerun. Config layout note: font is not readonly, so it doesn't need TextInputNoSelect: select handles go away when keyboard is minimized. Likewise for max number logfiles and backups. UPDATE: this is now also run on startup to set fontsize in configs saved by user; else user must click Apply each run. -------------------------------------------------------------------- """ # there be much magic here... App.get_running_app().dynamic_font_size = fontsize # abs, not + 'sp' # _not_ needed to force Log's filelist minimum_width to # recalc for scrolling - happens auto when font changed; # see the Log's tab's code in tab-change handler ahead ##self.width = self.width def do_explore_backups(self): """ On Help tab's 'explore TO SYNC backups.' This uses the TO setting on Main tab, but it's too tight to put there. TO may be anything at this point if was entered manually. """ topath = self.ids.topath.text # path on another tab if not self.try_isdir_nohang(topath): # hangware on win [1.1.0] self.info_message('TO on Main tab is not a folder.', usetoast=True) elif not os.path.isdir(osjoin(topath, '__bkp__')): self.info_message('TO on Main tab has no __bkp__ folder.', usetoast=True) else: self.launch_file_explorer(osjoin(topath, '__bkp__')) def on_backups_clicked(self, toggleon): """ Warn users of the UNDO consequences of toggling off SYNC backups in the Config tab. Because it's a major risk, and people don't read docs, right?... """ trace('backups on_touch_up') if not toggleon: self.info_message( 'CAUTION: backups disabled.' '\n\n' 'Disabling SYNC backups may save ' 'some storage space and make SYNCs run faster, ' 'but also makes it impossible to use UNDO to ' 'roll back changes made by SYNCs run while ' 'this toggle is off.' '\n\n' 'Please use with care, and see the user guide ' 'for more details.', usetoast=False) def on_keepscreenon_clicked(self, toggleon): """ [1.1.0] Users decide if screen should timeout or be kept on. This toggles the SDL2 flag flavor; for this to work, p4a's redundant wakelock alt must be disabled in buildozer.spec. Could do this in the .kv too, but parallels backups tap. Note: False disables both Android timeouts and PC screensavers. This might be limited to Android via p4a, but there's no simple way to disable p4a's wakelock code as there is for sdl2's alt. See App.on_start()s windows section for more background. """ # True/on disables PC screensaver + Android screen timout trace('keep-sceen-on:', toggleon) Window.allow_screensaver = not toggleon # See also: chooser icon/list style configs handled in the popup's code #======================================================================= # LOGS-TAB ACTIONS #======================================================================= def log_ops_preface(self, picklogitems): """ Fetch and verify path of selected logfile in Logs tab. Never get here unless there are logfiles to choose (?), which implies that log folder created and accessible. """ # btnsgroup is a single-selection list btngroup = picklogitems.children btndowns = [btn for btn in btngroup if btn.state == 'down'] logfilepath = osjoin(self.logfile_path, btndowns[0].text) if btndowns else None # it's possible to deselect the sole radio-buttons selection # update: no longer possible after added allow_no_selection=False # UPDATE: but this test still fires if there are no logfiles yet (?) if not logfilepath: self.info_message('Please select a logfile.', usetoast=True) return None # this seems very unlikely with custom chooser and name globs, but... if not os.path.isfile(logfilepath): # assume won't hang [1.1.0] self.info_message('Logfile path is not a file.', usetoast=True) return None return logfilepath def truncate_long_lines(self, text, maxline=400): # was 512 [1.1.0] """ Kivy text bug: displays a black box for very long lines (!). To work around, truncate long longs, at some cost in speed (though this is minor, because text is just the tail here). Actual maxline limit is unknown; constant works in practice. Kivy's code tries to trim lines, but fails: please fix this. UPDATE [1.1.0]: at the original maxline=512, one black line has been seen in the wild - among many thousands, but it's a glitch. Unfortunately, there is no absolute cutoff: per testing, lines > 512 (and up to 600+) worked with blackouts, so this must depend on context (or fate?), and scaling lines back further may discard useful info. As a compromise, change to maxline=400 to reduce blackouts risk further, but this is still a hueristic guess. Kivy: fix me!! """ return ''.join( [line + '\n' if len(line) <= maxline else line[:maxline] + '...\n' for line in text.splitlines()]) # TAIL------------------------------------------------------------- def purge_the_stupid_kivy_textinput_cache(self): """ -------------------------------------------------------------------- Empty the TextInput widget's cache, which causes fatal memory explosion on most Android phones tested if the text content is changed regularly. Called by TAIL and WATCH whenever the widget's text is reset. There's no way this misfeature should be so hidden; it cost days of hardcore dev time. See WATCH for more info (and rant). If this cache has any benefit, it's hardly universal. Note [1.1.0]: kivy.uix.textinput._textinput_clear_cache() runs the two removes here, but also gets rid of some cached TI weakrefs. Per macOS testing, this call has no extra effect: the app (which uses the code here) is always ~15M larger than source - whether source uses the code here or the alternative. This holds true both at startup and after running the same ops, and both app and source start near 100M and hover around 150~200M after running many ops and logfile views. Kivy requires a lot of memory... -------------------------------------------------------------------- """ # stop the #$@%* memory blowup! #from kivy.cache import Cache Cache.remove('textinput.label') Cache.remove('textinput.width') def do_logs_tail(self, picklogitems, prefacesize=512): """ -------------------------------------------------------------------- Show the last part of a selected logfile on demand. Scroll to the end (where summaries are) for ease, and add a brief preamble from the start of the log. This is usually enough info, and avoids full loads of large logfiles that may exceed kivy limitations or require an Android Intent multi-step interaction. Caution: seeking to a random byte position in a text file can return partial/split Unicode characters, which will fail to decode and raise exceptions (seen in the wild). Both here and in WATCH, user errors=replace to replace the partial-character bytes (with '?', probably). We don't need to run text through ascii() for GUI display, as Kivy shows Unicode code points sans glyphs as a rectangle. We do need to re-enable hscroll by resetting text size, on text sets (here+), and width changes (phone rotate, pc resize). UPDATE: scaled tailsize preset config down from 32k to 10k due to an odd graphics-memory drain; see WATCH's update ahead. Also set max size to 256k; the prior 512k seems a bit iffy. ---- Note [1.1.0]: the auto-scrolls here and in WATCH do nothing if a prior manual scroll's ending animation has not finished. Neither moving the a-s code past enable_hscroll() nor running it with Clock.schedule_once() help. Tweaking scroll effects may, but this is as much usage issue as bug, and the required tweaks (if any be) have zero docs and wildly obfuscated code... UPDATE [1.1.0]: the prior Note's auto-scrolls bug has finally been fixed, by setting 'velocity' to 0 in the the auto-created x/y instances of the effect_cls set in the .kv file. This is stupidly hidden knowledge, which required numerous excursions in Kivy's source code, and trail-and-error guesswork in the end. The docs on this are so lean and bad that they qualify as rude! -------------------------------------------------------------------- """ logfilepath = self.log_ops_preface(picklogitems) if not logfilepath: return try: # now in configs tab, not arg tailsize = int(self.ids.maxlogstailsize.text.replace(',', '')) if tailsize > (256 * 1024): self.info_message('TAIL size too big in Configs: try OPEN.', usetoast=True) return logsize = os.path.getsize(logfilepath) logfile = open(logfilepath, 'r', encoding='utf8', errors='replace') logfile.seek(max(0, logsize - tailsize)) logtext = logfile.read() trace('logsize:', logsize) if logsize > tailsize: # drop partial line1, unless it's very long eoln1 = logtext.find('\n') if eoln1 != -1 and eoln1 < 512: logtext = logtext[eoln1+1:] # add either trim note at top, or preface + trim note in middle if prefacesize > (logsize - tailsize): logtext = '...LOG TRIMMED ABOVE HERE...\n' + logtext else: logfile.seek(0) preface = logfile.read(prefacesize) eolnN = preface.rfind('\n') # drop partial line at end if eolnN != -1: preface = preface[:eolnN+1] logtext = preface + '\n...LOG TRIMMED HERE...\n\n' + logtext # unlike Help/About tabs, it doesn't help to Clock.schedule_once() # this code: its lag is minor, and it still crashes if text too big logfile.close() logtext = self.truncate_long_lines(logtext) # avoid black box of death self.ids.logfiletext.text = logtext # replace gui text (not +=) # stop the #$@%* memory blowup! self.purge_the_stupid_kivy_textinput_cache() # auto-scroll to bottom left: see note above # textinput.cursor doesn't scroll (kivy bug): use scrollview wrapping it # stop prior scroll's animation, else auto-scroll is a no-op [1.1.0] self.ids.logfilescroll.effect_x.velocity = 0 self.ids.logfilescroll.effect_y.velocity = 0 # auto-scroll for new text self.ids.logfilescroll.scroll_y = 0 # content bottom on bottom edge self.ids.logfilescroll.scroll_x = 0 # content left on left edge # required on all text sets, phone rotations, pc width resizes self.enable_logs_text_hscroll() except Exception as E: self.info_message('Cannot load logfile.\n\n' + 'Internal error: ' + str(E)) # WATCH------------------------------------------------------------ def do_logs_watch(self, picklogitems, tailsize=(2 * 1024), secspertail=1.0, autostopticks=60): """ -------------------------------------------------------------------- Sample the end of a logfile once per second. This may bog down the runing thread (though see NOTE), but is useful as a simple status check that's more than the Main animation. Use TAIL or OPEN after the run exits for a better look, or TAIL for a one-time look that won't auto-update. WATCH is disallowed if the file selection is bad, or no action is running (there's nothing to watch; use TAIL); and is auto cancelled if still running at action exit (else it burns CPU and battery pointlessly thereafter if the user leaves it on). WATCH is also disallowed if the logfile selected is not the latest at the top (i.e., the new logfile created by the action running). This normally won't be an issue, as the latest log is preselected. Forcing this log sans select may be confusing. => this is moot in 1.2.0: auto uses current run's log WATCH relies on the fact that script output is imediately flushed. Flushes may add some runtime, but testing so far says it's no worse than piping output to a file in Termux's shell. See also errors=replace notes in TAIL callback above. NOTE: in testing, using WATCH while a Main action is running does NOT degrade action speed, and MAY even make it faster, due to Android's "squeaky-wheel-gets-the-grease" scheduler. A normally 198-sec test often took just 168 secs with WATCH... UPDATE: WATCH has been seen to slow actions by 10% on WIndows. ==== UPDATE: very-long-running actions (e.g., 1~1.5hours) usually work fine, but two have been seen to fail on an Android 9 Note10 when WATCH was left on: the redrawn screen looked briefly like a bad acid trip, and the GUI soon hung/died with a black screen - though the foreground service kept running. Per days of research and logcats, this was caused by the GPU running out of memory it shares with the CPU for buffers and such (not the GPU's own memory). The logcat after errors had 100s (or 1000s) of lines like this: ... 9581 W Adreno-GSL: <sharedmem_gpuobj_alloc:2706>: sharedmem_gpumem_alloc: mmap failed errno 12 Out of memory ... 9581 E Adreno-GSL: <gsl_memory_alloc_pure:2270>: GSL MEM ERROR: kgsl_sharedmem_alloc ioctl failed. ... 9581 I python : (PPUS) In text hscroll enabler The best guesses are that Kivy's TextInput leaks memory when text is reset frequently; and/or there is a glitch in the GPU driver (or Android graphics-memory manager) which sometimes fails to get a clue and run garbage collection. It's happened in Unity too... This is also intermittent and unreproducible, unfortunately: two 1.5-hour WATCHes the next day worked fine with the same device, actions, and content. [Later: TAIL was seen to explode shared graphics memory on each tap too, and more reiably crashed the GUI.] A handful of coding fixes were tried here, including forced python gc.collect(); logfiletext.canvas.clear(); setting .text='' to clear; disabling all text-handling code; scheduling the set with Clock; the private textinput._textinput_clear_cache(); and tweaking the timer callback - don't save ref, move to top-level func to avoid closure, and use schedule_once vs schedule_interval(). [Callback is moot for TAIL which still grows shared graphics memory on each tap, and doesn't explain why the Fold4 doesn't have the issue]. None had any effect on graphics memory growth. Per Android Studio's Profile attached to the running app, memory kept increasing linearly on each WATCH tick, but leveled off at 1~2G when no error occurred. Each TAIL seemed to spike up graphics memory too, though this isn't perpetual like WATCH. And Kivy's TextInput source code is about as convoluted as it gets: it's all opaque graphics ops at the bottom. The only things that DID make an impact were turning off WATCH (this somehow triggers a graphics-memory reclaim); and replacing live file text with a few static lines (this doesn't blow up memory badly, presumably because the text size was below some threshold). Given that actions won't run 1.5 hours for most users' content (that was for 200G, 160k files, and 14k subfolders), this seems unlikely to be much of an issue in practice, and can generally be cured by running the action again (or letting the service finish). And really, after working around a truckload of Kivy bugs, this seems one too far. Hence, this is a preliminary PUNT, with the following tweaks: - Set android:largeHeap="true in the Android manifest. This won't help if the GPU/other isn't getting a low-memory signal, or the java heap that this mod increases is moot for native/Kivy code. [UPDATE: dropped this - it may increase chances of a process kill. That would apply more to a cached GUI process than a foreground- service process, but it risks one kill to try heading off another: developer.android.com/topic/performance/memory-overview#SwitchingApps Also: TAIL gm growth rapidly hits device ram size; more won't help!] - Scale down the sizes (tailsize) of both WATCH and TAIL text. WATCH was cut from 3k to 1k (you could see only 1/3 of it without very fast scrolls anyhow); and TAIL's default was changed from 32k to 10k (just the end matters, and scrolls to the top were tedious as it was). [UPDATE: WATCH was bumped up to 2k after the solution ahead, but TAIL stayed at 10k because 32k takes more time/space and is generally TMI.] - WATCH will now be turned off automatically at 60 ticks (seconds). There's no reason to leave it on perpetually (battery use alone is a compelling argument), and it's not worth a GUI hang; the Main tab's animation should be enough for status, and WATCH can be restarted. [UPDATE: this was kept despite solution below: minimize battery draw, and CPU use for other apps in general.] - The prior two mods will be applied on all platforms, even though problems were only seen on Android; the scope is unclear, and bugs that can't be recreated or fixed merit extra caution. [UPDATE: fast TAIL taps later proved to be a reliable crasher, but only if used atypically - the cache cleared itself in time.] - It _may_ help to add android:hardwareAccelerated="false" to the Android manifest, but this is tbd and unknown (and unverifiable!). buildozer/p4a set this to "true" automatically, so maybe not. [NO: this had no effect on Note10 or Note20U (and required manual manifest template edits - can't have both true and false for attr).] Go with these for 1.0.0 and wait to see if any bug reports come in. If they do, WATCH could be further neutered to automatically turn off even sooner (10 seconds?). But at some point, this probably is a red herring; time to gui death may be > time to battery empty... ==== UPDATE: the above did not help on Note9, Note20U, or S23U: all explode graphics memory for TAIL taps rapidly until it hits device's available memory, then freeze/crash - along with other apps open on the machine. => BUT not the fold4: uses 'other' memory, and no memory explosion => WHY? - it's not because hardware acceleration graphics is off Final tries: - Recoded to remove/buid/add TextInput widget dynamically: NO EFFECT, and made scrolling and colore config difficult - Recoded as Label: worked on macOS, FAILED on Android, with logcat: I python : (PPUS) logsize: 765339 I python : diffall 3.3 starting I python : ------------------- I python : Exception: Unable to allocate memory for texture (size is -1949184896) I python : Exception ignored in: 'kivy.graphics.texture.Texture.allocate' I python : Traceback (most recent call last): I python : File "/.../kivy/core/window/__init__.py", line 1627, in on_draw I python : Exception: Unable to allocate memory for texture (size is -1949184896) F DEBUG : .../kivy/core/text/_text_sdl2.so (offset 0x4000) ==== **SOLVED** See purge_the_stupid_kivy_textinput_cache() for the fix. Called both here and in TAIL when logfile text display is reset to new content TextInput STUPIDLY saves prior displays' state in a kivy.cache.Cache, with a 60-second timeout. That's why only WATCH triggered the issue before: you have to tap TAIL in rapid succession to cause an issue. Simply purging the Cache before or after each text reset clears enough memory to make this a non-issue: memory in the profiler spikes up momentarily, but immediately goes back down to a reasonable level. Why in the world this isn't called out explicitly with a disable option in the widget is anyone's guess. Sans manual purge, it makes Kivy text all but unusable on most Android devices except for trivial static display... The Fold4's diff remains an unsolved mystery; an opengl version skew? ==== NOTE: [1.2.0] Now ensures that new logfiles are not auto-pruned when the device's clock is far in the past, but time changes may make them not appear at the top of the sorted file list; cannot WATCH, because this currently checks that watched file is top/latest (and more likely to try to watch a truly older file than this). INTERIM [1.2.0] Resolve the preceding by changing the old-file test to simply issue a popup (with toast on Android) to alert the user, instead of prohibiting WATCH altogether. This allows users to scroll down to older name and still watch run if time changes. UPDATE [1.2.0] In hindsight, it's weird to require a logfile pick in the GUI for WATCH, when it only makes sense if the current run's file is piked, and the app already knows what that is! HENCE, 1.2.0 redesignd this to save the known logfile only at action start, and use it in WATCH instead of GUI picks. This elinates odd error checks for picks, and obviates the issue of newest logfile != current run (and users don't have to root around to find it). TAIL and OPEN still use the GUI pick, but WATCH and EXPLORE do not. TBD [1.2.0] This could also auto select and scroll to the run's logfile used, but this is perilous in Kivy (see tab switch!), and why do here only and not for tab switch? In the end, run's log will always be at list top, except for rare and temporary time changes. -------------------------------------------------------------------- """ def ontimer(dt): if self.logs_timer_counter == autostopticks: offmsg = 'WATCH was turned off to save resources: press WATCH to restart.' self.ids.logfiletext.text = offmsg self.ids.logfilescroll.scroll_x = 0 # scroll to left: 1 line self.enable_logs_text_hscroll() # user can hscroll if need self.ids.logfilewatch.state = 'normal' # turn off watch btn auto endwatch() # and end current watch loop else: self.logs_timer_counter += 1 logsize = os.path.getsize(logfilepath) # current size, func scope logfile = open(logfilepath, 'r', encoding='utf8', errors='replace') logfile.seek(max(0, logsize - tailsize)) logtext = logfile.read() logfile.close() # drop partial line1, unless it's very long eoln1 = logtext.find('\n') if eoln1 != -1 and eoln1 < 512: logtext = logtext[eoln1+1:] logtext = self.truncate_long_lines(logtext) # avoid black box of death self.ids.logfiletext.text = logtext # replace gui text (not +=) # stop the #$@%* memory blowup! self.purge_the_stupid_kivy_textinput_cache() # auto-scroll to bottom (and left on exit): see [1.1.0] note in TAIL # enable-hscroll is required on text sets, rotations, width resizes # stop prior scroll's animation, else auto-scroll is a no-op [1.1.0] self.ids.logfilescroll.effect_x.velocity = 0 self.ids.logfilescroll.effect_y.velocity = 0 # auto-scroll to bottom (only: user may want to see end of paths) self.ids.logfilescroll.scroll_y = 0 # scroll to end (but not left!) self.enable_logs_text_hscroll() # user can hscroll long lines if not self.script_running: # ended since last ontimer() self.ids.logfilescroll.scroll_x = 0 # scroll to left (only) now self.ids.logfilewatch.state = 'normal' # turn off watch btn auto endwatch() # and end current watch loop def startwatch(): # nit: open and explore could be left on for logbtnid in ('logfiletail', 'logfileopen', 'logfileexplore'): self.ids[logbtnid].disabled = True # start auto-rescheculed timer loop self.logs_timer_counter = 0 self.logs_timer_callback = ontimer # keep a ref (moot) self.clockevent = Clock.schedule_interval(ontimer, secspertail) def endwatch(): # reenable other actions for logbtnid in ('logfiletail', 'logfileopen', 'logfileexplore'): self.ids[logbtnid].disabled = False # end auto-rescheduled timer loop if not hasattr(self, 'clockevent'): # if btn stuck on (it happens) pass # ignore: toggle may bounce #trace('Watch is on unexpectedly') else: self.clockevent.cancel() # end timer loop del self.clockevent # remove event hook def latest_logfile_selected(logfilepath): # NO LONGER USED # never called if no logfiles in list, but test just in case # [1.2.0] this test is bogus if device's date/time is askew... return (self.latest_logs and self.latest_logs[0] == logfilepath) # both have self.logfile_path #------------------------------------- # do_logs_watch: on watch button press #------------------------------------- trace('toggle state:', self.ids.logfilewatch.state) if self.ids.logfilewatch.state == 'down': # toggled up=>down # [1.2.0] use run's logfile, not gui pick if not self.script_running: # watch is moot self.ids.logfilewatch.state = 'normal' # turn off auto self.info_message('No running action to watch.', usetoast=True) else: logfilepath = self.current_run_logfile_path # run's, not gui pick startwatch() # that's it; use global elif self.ids.logfilewatch.state == 'normal': # toggled down=>up endwatch() # end loop, leave hscroll #------------------------------------- # pre-1.2.0-final version (temp) #------------------------------------- """ logfilepath = self.log_ops_preface(picklogitems) # in func scope if not logfilepath: # invalid path self.ids.logfilewatch.state = 'normal' # turn off auto elif not self.script_running: # watch is moot self.ids.logfilewatch.state = 'normal' # turn off auto self.info_message('No running action to watch.', usetoast=True) #elif not latest_logfile_selected(logfilepath): # watch is bogus # self.ids.logfilewatch.state = 'normal' # turn off auto # self.info_message('Cannot watch prior-run logfile.', usetoast=True) else: # [1.2.0] weaken to cautionary popup only: time skew if not latest_logfile_selected(logfilepath): self.info_message('Watching an old logfile.', usetoast=True) startwatch() """ # OPEN------------------------------------------------------------- def do_logs_open(self, picklogitems): """ -------------------------------------------------------------------- Open logfile in another app, to sidestep size limitations in the kivy text widget. This is simple on PCs, but horrifically convoluted on Android, since Android 11 (which dropped API support for file:// URIs). Requires a FileProvide and reams of proprietary Java and manifest code, even though the files are in shared storage that apps can already access. Why did Android turn so corporate and nazi?... [1.1.0]+ (feb24): on Linux, background xdg-open via &, else it blocks (and may hang) GUI on Windows WSL2 Linux + Ubuntu (only). xdg-open also requires a 'sudo apt install xdg-utils' on WSL2 Ubuntu (only). The '&' is harmless in other Linux contexts. OPEN might run explorer.exe for Windows editors (else must also install gedit/other in WSL2), but this doesn't open in the logfiles dir for EXPLORE (and users alreayd have to instal tcl/tk too). """ """ ------------------------------------------------------------- Try #1: simple loads do not work, even with [android:largeHeap="true"] in manifest. Files ~512k oky, but 1M+ take long to load, cannot scroll, and crash app. Alas, this seems to be a limitation in kivy or the TextInput widget, not android per se, but is a full stop fpr open(). # sys._debugmallocstats() # trace(sys.getandroidapilevel()) try: # open full with boosted heap size? logfile = open(logfilepath, 'r', encoding='utf8') # no errors logtext = logfile.read() logfile.close() self.ids.logfiletext.text = logtext # textinput.cursor doesn't scroll (kivy bug): use scrollview wrapping it self.ids.logfilescroll.scroll_y = 0 # content bottom on bottom edge self.ids.logfilescroll.scroll_x = 0 # content left on left edge # required now AND on rotations self.enable_logs_text_hscroll() except Exception as E: self.info_message('Cannot open logfile: %s.' % E) ------------------------------------------------------------- """ """ ------------------------------------------------------------- Try #2: Nope (a frustrated Unix last resort)... os.chmod(popuppath, 0o777) webbrowser.open(popuppath) ------------------------------------------------------------- """ """ ------------------------------------------------------------- Try #3: almost, but triggered file:// uri exception in logcat "exposed beyond app through Intent.getData() android.os.FileUriExposedException" even though the file is in shared storage and already accessible to the handler apps. Editorial: Android is fully retarded on this... activity = self.get_android_activity() #context = self.get_android_context(activity) Intent = autoclass('android.content.Intent') Uri = autoclass('android.net.Uri') File = autoclass('java.io.File') # filenames only intent = Intent() intent.setAction(Intent.ACTION_VIEW) intent.setData(Uri.fromFile(File(logfilepath))) activity.startActivity(intent) ------------------------------------------------------------- """ """ ------------------------------------------------------------- Try #4: the one that WORKED... Using Adroid FileProvider, and 'secure' content:// uris Other parts of this: 1) ./manifest-manual-application-postattrs.xml <provider> def, but insert location not supported by buildozer this must be in <application attrs>X</application> buildozer does only <application attrs X></application> and <application> cannot be repeated in <manifest>: https://developer.android.com/guide/topics/manifest/manifest-intro.html#filec 2) ./fileprovider_paths.xml <paths> folder def ref'd by <provider> in #1 (wth is this separate?) 3) ./buildozer.spec includes #2 (but not #1), via recent additions from github and enables androidx package, where FileProvider lives and sets gradle_dependecy to load androidx's package (no quotes, undoc) https://developer.android.com/jetpack/androidx/releases/core#core-1.9.0 4) ~/.../PC-Phone-USB-Sync/.buildozer/android/platform/build-arm64-v8a_armeabi-v7a/dists/usbsync/templates/AndroidManifest.tmpl.xml patched this file (only) to add #1 as a literal; YUCK https://github.com/kivy/python-for-android/issues/1964 (2019...) => LATER: and must manually REPATCH if this goes away after changing build args or "clean" (craps) => LATER: and must patch in .../usbsynctrial/... branch in dists too, for trial app (!!) Later: - need to add androidx pkg to build, for FileProvider - enabling androidx in bd.spec not enough - need gradle_dependency too - cannot find core lib in repositories searched (don't use quotes) - tried manually fetching androidx, to no avail (removed) (also triggered old .ds_store version# err)... - removing quotes in bd.spec seemed to help, but core 1.9.0 needs api33+; use 1.8.0... - located proper insert file: see #4 above, removed other tries - rm junk ctrl chars on <meta-data> from copy/paste off google doc page - ditto for something else copied off a google docs page - an indentation error was obscured by lack of logcat output; run twice - tried coding own FileProvider subclas in java: not required, use androidx's in manifest+autoclass() - java subclass example has ctrl characters, and lacked a required ";"... - <path> setting proved wonky - use undec <root-path> and full path to file ------------------------------------------------------------- """ """ ------------------------------------------------------------- in java: File imagePath = new File(Context.getFilesDir(), "my_images"); File newFile = new File(imagePath, "default_image.jpg"); Uri contentUri = getUriForFile(getContext(), "com.mydomain.fileprovider", newFile); from jnius import JavaClass, MetaJavaClass class MyFileProvider(JavaClass, metaclass=MetaJavaClass): __javaclass__ = 'androidx/core/content/FileProvider' __javaconstructor__ == ( '()V', '(Ljava/lang/String;)V', ) package com.quixotely; import androidx.core.content.FileProvider; public class MyFileProvider extends FileProvider { public MyFileProvider() { super(R.xml.file_paths) } } ------------------------------------------------------------- """ """ ------------------------------------------------------------- late step: don't need Java subclass in ./src/c/l/u/.java: no diff manifest => android:name="androidx.core.content.FileProvider" not "com.quixotely.FileProvider" (doesn't exist in java realm) #FP = autoclass('com.quixotely.usbsync.MyFileProvider') last step: <path> in paths file worked with 1st, not 2nd\ <root-path name="logfiles" path="." /> <external-path name="logfiles" path="Documents/PC-Phone_USB_Sync" /> no idea why the 2nd doesn't work, but life's too short to care... #trace('file=>', file, flush=True) #trace('path=>', file.getAbsolutePath(), flush=True) #trace('FP=>', FP, flush=True) #trace('uri=>', furi) #fp = FP() #trace('fp()=>', fp) TBD: also set 'text/plain' mime type with setDataAndType()? Android docs strongly suggest that data (content URI) is enough, and mime type is inferred from it autmatically. ------------------------------------------------------------- """ logfilepath = self.log_ops_preface(picklogitems) if not logfilepath: return # pcs are easy if not RunningOnAndroid: if RunningOnWindows: os.startfile(logfilepath) elif RunningOnMacOS: os.system('open "%s"' % logfilepath) # allow spaces: app name! elif RunningOnLinux: os.system('xdg-open "%s" &' % logfilepath) # likewise; [1.1.0]+ (feb24) return # android is not! activity = self.get_android_activity() context = self.get_android_context(activity) Intent = autoclass('android.content.Intent') Uri = autoclass('android.net.Uri') File = autoclass('java.io.File') # filenames only String = autoclass('java.lang.String') # optional FP = autoclass('androidx.core.content.FileProvider') # furi _must_ vary for full and trial apps: here and in manifest inserts # see service_in_progress_state() for more; also true for service names appsuffix = 'usbsync' if not TRIAL_VERSION else 'usbsynctrial' file = File(logfilepath) furi = FP.getUriForFile(context, String('com.quixotely.%s.fileprovider' % appsuffix), file) intent = Intent() intent.setAction(Intent.ACTION_VIEW) intent.setData(furi) intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION) activity.startActivity(intent) # and user picks registered handler app # or just os.startfile() on Windows, os.system('open %s') on macos... # EXPLORE---------------------------------------------------------- def launch_file_explorer(self, folder): """ Used for both logs-tab explore, and help-tab explore backups Java: Uri selectedUri = Uri.parse(Environment.getExternalStorageDirectory() + "/myFolder/"); Intent intent = new Intent(Intent.ACTION_VIEW); intent.setDataAndType(selectedUri, "resource/folder"); if (intent.resolveActivityInfo(getPackageManager(), 0) != null) { startActivity(intent); } else { // no file explorer app installed on device } [1.1.0]+ (feb24): on Linux, background xdg-open via &, else it blocks and may hang GUI on Windows WSL2 Linux (only); see OPEN; """ # pcs are easy if not RunningOnAndroid: if RunningOnWindows: os.startfile(folder) elif RunningOnMacOS: os.system('open "%s"' % folder) # allow spaces: app name elif RunningOnLinux: os.system('xdg-open "%s" &' % folder) # ditto; [1.1.0]+ (feb24) return # android is not! activity = self.get_android_activity() context = self.get_android_context(activity) Uri = autoclass('android.net.Uri') uri = Uri.parse(folder) # assumed absolute Intent = autoclass('android.content.Intent') intent = Intent(Intent.ACTION_VIEW) intent.setDataAndType(uri, 'resource/folder') try: activity.startActivity(intent) except Exception as E: self.info_message('No usable file-explorer app.', usetoast=True) # this and resolveActivity() always None, but intent can be started anyhow """ if intent.resolveActivityInfo(context.getPackageManager(), 0): activity.startActivity(intent) else: self.info_message('No usable file-explorer app.', usetoast=True) """ def do_logs_explore(self): # no need for a gui selection here, but ensure folder access? # tbd: seems gray - maybe it's up to explorers to grok the path? if not self.init_logfile_folder(): return logfilefolder = self.logfile_path # logfilepath's (if set) folder self.launch_file_explorer(logfilefolder) # abs(<shared>/Documents/<appname>) #======================================================================= # LOGFILE UTILITIES #======================================================================= # See also maxnumlogfiles in Config tab def current_logfiles_by_descending_name(self): """ -------------------------------------------------------------------- This is now used in three places: ensure same. Sorted by descending filename; because logfiles are named per host system's date/time, this is the same as descending creation time. Assumes self.logfile_path has been set, exists, and is listable (init_logfile_folder(), called before this, ensures all three), and returns a list of full paths, not basenames (files). -------------------------------------------------------------------- """ logpatt = LOGFILE_NAME_PATTERN globlogs = glob.glob(osjoin(self.logfile_path, logpatt)) sortlogs = sorted(globlogs, reverse=True) return sortlogs def on_tab_switch(self): """ -------------------------------------------------------------------- On tab switches to the Logs tab, reload its logsfilechooser so it shows any newly-added or in-progress logs (e.g., for Wait), iff the set of logfiles has changed (e.g., for a new run). This might be done in make_new_logfile(), but this wouldn't handle manual folder changes made by the user, or auto-prunes. TabbedPanel provides current_tab, which can be monitored for changes by [on_current_tab: <this>]. Nothing else used here is documented (the code was largely discovered by trace prints), and the _update_files call is _private... Subtle: it's impossible for a pruned logfile to appear in the Logs tab's file list, because the delete triggers a reload here if the user switches from Main (action start+prune) to Logs tab. Caveat [now moot]: the chooser update causes a momentary flash in the file list whenever switching to Logs tab, but detecting new logs via state+filesystem may be just as slow. ---- UPDATE: as an optimization, only update the chooser's file list if new logs have arrived since last update or init. This avoids the flash in full, except after a Main action. Check needs to see if entire list of logfiles has changed (not just most recent file, currlogs[-1]), and update if so. Else, will be junk entries at end if logs have been trimmed. UPDATE: now also forces chooser's selection to most-recent logfile, if the folder has changed. This is a convenience; users will almost always want latest for WATCH and TAIL (and WATCH could simply use the latest always, but it works as is). ---- UPDATE: recoded with a custom scrolled file-selection widget. This was initially coded to use a stock kivy FileChooser with FileChooserListLayout, but this was a major pain in the app... autoselect was elusive, prior selections were not cleared after _update_files() (autoselect or not), icon-view mode didn't show full filename, and a popup requires pointess extra taps. See _dev-misc/todo.txt for the filechooser coding abondoned. NIT: the current coding for the selection list assumes that all lognames are the same size in a mono-width font. Changing this in kivy to be more flexible seems the stuff of madness. NIT [now moot, per UPDATE below]: the selection list doesn't hscroll if user sets a font so big that it overflows screen. But names are fine on a Note20U at settings already large enough to break (former) filecooser's iconview. (Editorial: Kivy's MVC filechooser is unneccessarily convoluted, functionally weak, and just plain buggy. And why is there no basic selection-list widget as in tkinter? Improve me, please. Sizing for scrolling also seems a nonorthogonal mess; it smacks of CSS, but with a built-in N-minute app-build delay per tweak.) NOTE: no longer needs to forcibly cancel selection on the logs display: TextInputNoSelect now disables selection in full. (Formerly: despite all settings, it's still possible to select text, and there seems no UI deselect. clear_selection() here removes selection because it won't auto disappear on a tab, but this doesn't fix lingering selection handles issue/bug!). ---- UPDATE: Logs' custom file toggle-button list IS now horizontally scrolled too (in addition to vertically). This matters for very large fonts and/or very narrow displys (see Fold4 cover screen). The code to make this happen is arcane in the extreme; but works. ---- UPDATE: this now also works around the lag in Help and About text display, by simply setting the text on the next GUI cycle. This is not documented in kivy docs, and the advice on the web is way off base (it suggest recyvleviews, and there was an optimization for this in kivy that largely failed). Without this workaround, there is a momentary twitch on all platforms, and a horrific delay of ~8 seconds for 15k Help on Windows that's all kivy widget time, not file read time (a bug!). Note that this has to be done at tab-switch time; the lags occurred when text was set on startup, and Clock scheduling the sets at startup didn't help. In general, the code here would have to be used whenever about to display text. Note: this now sets help/about text just once, but this seems moot: if reset on tab reopens, there is no load pause or jump to top-of-text. Perhaps TextInput is smart enough to detect 'is' equality on resets? Note [1.1.0]: delayed text-content sets are now also employed to avoid glitchy lag in info-message popups on some slower phones; see info_message(). ---- UPDATE [1.1.0]: for Logs tab, this now avoids a Kivy race-condition crash for ToggleButtons, by setting all .group to None before removing from children lists. This ensures that Kivy's TB button-press code won't try to .remove a TB's weakref from its group list after an async garbage-collection callback has already .removed them. Without this workaround, a Logs toggle-button press can crash the GUI with an uncaught exc for Kivy's .remove. A rare Kivy bug to be sure, but it was witnessed once on macOS, and perhaps aggrevated by action threading on PCs: async GC callbacks may be more likely when thread switching is intense. Deleted TBs may be GCd at any time, including in the middle of the TB button-press handler code. Setting .group also invokes a .remove for the prior group's list in Kivy TB code, but this won't crash because the buttons' are still held in child lists, thereby preventing GC and async GC callbacks. It's not fully clear why Kivy uses the GC callback to .remove from groups in general; this is not thread safe. Kivy also builds a bogus group for buttons changed to None, but this seems harmless. ---- UPDATE [1.1.0]: catch double and triple taps on logfile buttons, and auto-run just TAIL for double, and TAIL+OPEN for triple. Kivy fires double on the way to triple, so running just OPEN is stateful. Implementation: it suffices to register for just the last button added: all logfile buttons get the event, and the selected button's text is fetched from the 'down' button in the action callback run. Don't need [logfilebtn.collide_point(*touch.pos)]: any tap triggers. Need [ToggleButton.on_touch_down(logfilebtn, touch)]: to select last. Taps bubble through all logfile buttons (only): add trace()s to see. ALSO [1.1.0]: less padding here on Windows and Linux else buttons oddly large, and less spacing on all PCs in .kv else gaps too big. ALSO ALSO [1.1.0]: prior point is superseded by new density-scaled pixels globally - here, chooser buttons, and in .kv (see its docs). ---- Note: [1.2.0] ensures that new logfiles are not auto-pruned when the device's clock is far in the past, but time changes may make them not appear at the top of the sorted file list; user must find. TBD [1.2.0]: the top of the file list is not necessarily the most recent if host clock moved back, and auto-selecting this may not be useful. Might set it to the last run's logfile save at run launce (and used in WATCH), but this seems iffy and dangerous: the last run may have had issues, and the Kivy bugs worked around in code here makes auto select+scroll a very perilous business. As is, top WILL be most recent the vast majority (or all) of the time. -------------------------------------------------------------------- """ trace('tab switch') if self.ids.toptabs.current_tab == self.ids.helptab: trace('help tab') def sethelptext(dt): self.help_message = self.stage_help_message if not self.help_message: Clock.schedule_once(sethelptext) elif self.ids.toptabs.current_tab == self.ids.abouttab: trace('about tab') def setabouttext(dt): self.about_message = self.stage_about_message if not self.about_message: Clock.schedule_once(setabouttext) elif self.ids.toptabs.current_tab == self.ids.logstab: trace('logs tab') # make if needed, bail if fail if not self.init_logfile_folder(): return # refresh logsfilechooser iff needed currlogs = self.current_logfiles_by_descending_name() if currlogs != self.latest_logs: trace('logs changed') pickscroll = self.ids.picklogscroll # won't scroll sans size pickitems = self.ids.picklogitems # btns on a layout in a scrollview # avoid Kivy race-condition crash [1.1.0] for logfilebtn in pickitems.children: logfilebtn.group = None # else exc if GC during press pickitems.clear_widgets() # delete lfbtns, GC now or later # not in .kv (but would work there too) pickitems.size_hint = (None, None) pickitems.bind(minimum_width =pickitems.setter('width')) # set width on minwidth pickitems.bind(minimum_height=pickitems.setter('height')) # mins calc sizes auto #pickitems.halign = 'center' # use AnchorLayout for path in currlogs: # add logfile radio button to v+h scolled list # log_ops_preface() expands basename text on Logs actions logfilebtn = ToggleButton(text=os.path.basename(path)) logfilebtn.group = 'picklog' logfilebtn.state = 'normal' logfilebtn.font_name = 'RobotoMono-Regular' logfilebtn.padding = ( # initial tweak, superseded [1.1.0] # (24, 24) if RunningOnWindows or RunningOnLinux else (32, 32)) # scale to screen density, not platform or device [1.1.0] (kivy.metrics.dp(14), kivy.metrics.dp(14))) logfilebtn.allow_no_selection = False logfilebtn.size_hint = (None, None) logfilebtn.halign = 'center' logfilebtn.valign = 'middle' logfilebtn.bind(texture_size=logfilebtn.setter('size')) # set size on texture_size pickitems.add_widget(logfilebtn) if currlogs: # possibly [] numlogs = len(currlogs) latest = pickitems.children[-1] # default adds at ix=0 (!) latest.state = 'down' # auto select latest log # and scroll to selected file at top - needed despite rebuild! pickscroll.scroll_y = 1 # content top on scroll's top edge # register touch callback for multitaps [1.1.0] # see update above for more on this implementation def on_logfile_down(touch): ToggleButton.on_touch_down(logfilebtn, touch) # last added if (touch.is_double_tap and self.ids.logfilewatch.state == 'normal'): # auto TAIL, if not WATCH self.do_logs_tail(pickitems) if touch.is_triple_tap: # auto OPEN, always self.do_logs_open(pickitems) return False # bubble to other logfile buttons logfilebtn.on_touch_down = on_logfile_down # last added only self.latest_logs = currlogs # save for next switch to Logs # DEFUNCT (if currlogs) # set pickscroll.width = pickitems.width # else scroll width=100px default, but disables hscroll # no longer needed or correct - see the UPDATE below """ pickitems.bind(minimum_width=pickscroll.setter('width')) """ # enables filelist hscroll, but only after a window resize, # and so misses both startup and apply for huge font sizes; # punt - small windows and large fonts that hclip Log's # filelis are mostly unusable in lots of other ways too; # # UPDATE: equivalent now done in .kv file, where bindings # nested in expressions are easier, and fire automatically; # this also obviates the manual scroll-width binding above, # which kept this alternaive from working for font sizes; """ def set_pickscroll_width(root, rootwidth=...): pickscroll.width = min(pickitems.minimum_width, rootwidth) self.bind(width=set_pickscroll_width) """ # END DEFUNCT def init_logfile_folder(self): """ -------------------------------------------------------------------- Make the logfile folder if it doesn't yet exist, and save its path in instance and GUI (a kivy property). Also verify access, but don't request permission here; seems too much nagging. Return True iff logfile folder exists and is listable, whether made anew or not. Called for all Androids on each Main action run, as well as on switches to the Logs tab in the GUI (but no longer on startup, as the next para explains). Documents/ works sans permission in Android 11+, but the os.mkdir() won't work on pre-11 sans runtime permission - which users can deny, and which won't yet be given when the nonmodal permission dialog is posted in App.on_start() (where this call failed on pre-11 Androids). It's unlikely that logfile access failures reflect storage permission issues (Documents/ on Android is always accessible?), but prompt for a restart just in case. This seems moot for PCs, including macOS. UPDATE: maybe not - macOS pops up permission dialogs on first access. UPDATE [1.1.0]+ tweak the message to clarify that an app restart is not needed on macOS (probably), and qualify the opening as "on Android" because it won't apply anywhere else. See reset_main_picker_path(). UPDATES [1.1.0]+ (feb24): auto-create the ~/Documents folder if it does not already exist; required for Windows WSL2 Linux + Ubuntu (only); could also use os.makedirs() to make all path parts, but it's overkill; -------------------------------------------------------------------- """ docspath = self.storage_path_documents() if not osexists(docspath): try: os.mkdir(docspath) # [1.1.0]+ (feb24) except: pass # and fail below self.logfile_path = osjoin(docspath, LOGS_SUBFOLDER) # used multiple places if not osexists(self.logfile_path): try: os.mkdir(self.logfile_path) except: self.info_message('Cannot make logfiles folder.' '\n\n' 'If you just granted storage permission on Android, ' 'please try restarting this app now.' # [1.1.0]+ '\n\n' 'Otherwise, retry after ensuring that ' 'drives have been mounted and recognized.' + ( '' if not RunningOnMacOS else # [1.1.0]+ '\n\n' 'On macOS, you may be able to simply rerun the last ' 'request if you just handled the permission popup.' ), usetoast=False) return False # osexists() doesn't imply accessibility try: os.listdir(self.logfile_path) # assume won't hang [1.1.0] except: self.info_message('Cannot access logfiles folder.' '\n\n' 'If you just granted storage permission on Android, ' 'please try restarting this app now.' # [1.1.0]+ '\n\n' 'Otherwise, retry after ensuring that ' 'drives have been mounted and recognized.' + ( '' if not RunningOnMacOS else # [1.1.0]+ '\n\n' 'On macOS, you may be able to simply rerun the last ' 'request if you just handled the permission popup.' ), usetoast=False) return False return True # it's there, and we can access it def make_new_logfile(self, opname): """ -------------------------------------------------------------------- On Main action runs. Ensure log folder each time: may fail on pre-11 android if no runtime permission granted by user. New logfile is closed+reponened in subprocess action modes. ---- UPDATE [1.2.0]: ensure that a newly created logfile is at top of the logfiles list ordered by descending names. Else the new log won't be considered newest by later sorts: alert the user, bail. Logiles are labeled according to the host machine's date and time, and then sorted by name for display and pruning. This can fail badly if the host's date/time is wrong. In one case, a device that couldn't connect to a network to get the latest time thought it was many years in the past (2020 vs 2024) - new logfiles were oldest by name, and runs pruned their own new logfiles, leaving no record of anything happening (even though actions ran and potentially made changes). The glitch requires threads (services reopen the logfile post prune), but threads are always used on PCs and Android 8 (the erring device in question), and display order will still be wrong for services. TO ADDRESS, check that the new logfile is latest by a name sort (which suffices to detect name-label/creation-time skew), and alert the user and cancel the action if not true to avoid stealth runs. The sort here mimics both on_tab_switch() and auto_clean_logfiles(), which handle displays and prunes. The alternative is to always sort by file modtimes instead of names, but this seems risky: it requires additional filesystem access (that may hang), and modtimes are notoriously wonky on some filesystems. And really, this WOULD NOT HELP: if the system thinks it's 5 years ago, that's what it will stamp in new file creation/modification times! Logfile names reflect creation time the same as filesystem times. ---- EXCEPT [1.2.0]... the preceding scheme renders that app unusable for one hour once a year, after clocks fall back for DST changes. It might also preclude usage for awhile after flying to a new timezone that's earier by more than the duration of the flight. Labeling files with UTC time (or sorting by same in the filesystem) would help with DST and timezone skew, but this would make logfile names not very useful to human readers, and would NOT HELP if the device's time or timezone settings were wrong - the labels or sorts would still be off (in the fail, 2020 is still < 2024, UTC or not). [Python's time.strftime() defaults to local time. It also accepts a UTC time.gmtime(), which normally returns non-decreasing values, but won't be as meaningful to users (GMT time in logfile names?), and can still return a lower value than a previous call if the system clock has been set back between the two calls. Py's time.monotonic() cannot go backwards and is not affected by system clock updates, but only the difference in #seconds between the results of two calls is valid, and only during a given single process - #seconds since start would be meaningless in filenames, and this resets to 0 on each run!] HENCE, punt on the warning+cancel here, but use simpler option of just avoiding a prune of the new logfile. This will still display the new file at the end of the list (and may prune it on next run, and botches WATCH - till final 1.2.0, at least), but the run will at least work and keep its logfile long enough for users to spot device-time issue. In the case of DST and timezone changes, the new logfile likely won't be at the end of the sorted list, so it won't be removed on next run. ALSO issue a caution popup (which may be covered by summary popup for fast runs, but this is very rare) when order is askew by DST, timezone, or bad clock. AND ensured that Mergeall itself was NOT pruning the new run's __bkp__ sync back folder for an old date/time (an identical issue, formerly avoided by pruning before creating). -------------------------------------------------------------------- """ if not self.init_logfile_folder(): return False logdir = str(self.logfile_path) logname = time.strftime('date%y%m%d-time%H%M%S--%%s.txt') % opname logpath = osjoin(logdir, logname) try: logfile = open(logpath, 'w', encoding='utf8') except: self.info_message('Cannot create logfile.' '\n\n' 'Run cancelled - please resolve and rerun.', usetoast=False) return False # [1.2.0] is new logfile at top of name-ordered descending sort? currlogs = self.current_logfiles_by_descending_name() # >= 1: new log made if logname != os.path.basename(currlogs[0]): self.info_message('Caution: logfile naming-order skew.' '\n\n' 'Logfiles are named with your device\'s date/time when ' 'they are created. This run\'s logfile is not newest by ' 'name, which may reflect a normal time change, but can ' 'also be a symptom of your device\'s date/time being ' 'improperly set too far in the past.' '\n\n' 'This causes new logfiles not to show up at the top in ' 'the Logs tab. They may also be pruned sooner than ' 'expected, leaving no record of actions\' changes. ' 'SYNC backups with dates in the past may similarly ' 'be pruned too soon, making UNDO impossible.' '\n\n' 'Please verify that date/time is okay on your device.', usetoast=False) return (logfile, logpath) # worked, okay, proceed with action def auto_clean_logfiles(self, newlogpath): """ -------------------------------------------------------------------- Just before each Main-tab action run, prune the logfiles folder to keep only the most recent 'maxlogs' logfiles to avoid taking up excess space. There's one log for every Main action run. Never called unless logfile folder is known to exist. Don't do on startup: would not handle new logs in session. Don't prune by age only: may not SYNC for weeks/months. Retains maxlogs (not -1), because the next log has been added. For user ease: always log, but auto-clean folder this way. TO backup folders are auto-cleaning too, but are pruned by mergeall.py on SYNCs having any backups (and configured there). Note: it's impossible for a pruned logfile to appear in the Logs tab's file list, because that list will be reloaded with the current set when the user switches from Main to Logs tabs in the GUI (the logfiles change triggers a reload: see on_tab_switch()). ---- [1.2.0] This now filters out the new run's logfile, newlogpath, from the list of files to prune: it may be there because the host's date/time was set far in the past. Else, an action would run but leave no record of changes in logfile, and summary popup can't extract (for threads; services reopen log). See make_new_logfile(). Nit: this will throw off the number of logs kept by +1, but this is temp and rare. We could simply run this _before_ make_new_logfile(), so that the new long will never wind up in prunes even if date/time is old (this is how Mergeall skirts the issue for its __bkp__ backup folder prunes); but that would have to dup much init_logfile_folder() code, else calling it here could trigger its popups twice in caller. Alternative coding - sets lose name-order sort: prunes = list(set(prunes) - set([newlogpath])) prunes.sort(reverse=True) -------------------------------------------------------------------- """ maxlogs = int(self.ids.maxnumlogfiles.text) # from Config tab logroot = str(self.logfile_path) # set on run startup if osexists(logroot): # none on first run # oldest last, per sort, known to be listable here currlogs = self.current_logfiles_by_descending_name() # full paths prunes = currlogs[maxlogs:] # [1.2.0] drop new logfile path if in prunes: date/time skew if newlogpath in prunes: trace('filtering newlogpath from prunes') prunes = [p for p in prunes if p != newlogpath] anyfailed = False for prunee in prunes: # globs have full paths trace('Pruning log:', prunee) # normally 0 or 1, unless failed try: os.remove(prunee) # skip Windows FWP: path short except Exception: anyfailed = True trace('This prunee failed, but pruning continued') trace('%s %s' % (sys.exc_info()[0], sys.exc_info()[1])) def enable_logs_text_hscroll(self, *args): """ -------------------------------------------------------------------- Workaround for one of about a dozen kivy bugs: hscroll lost on rotate. TBD: is this still needed (originally for Main scrolled run output)? YES: logs text still has no hscroll both initially and after rotations. NEW: call on logfile load AND scroll's on_height, NOT text's on_text. NIT: Window.on_rotate() may have worked too (and runs less often?). BUT: Window.on_rotate() seems to never fire despite docs; see on_start(). Workaround: on both scrollview's on_height and textinput's on_text, enable horizontal+vertical scrolling of program-run output in Main tab. This output may also be trimmed due to size limits in kivy TextInput: users should view full logs in Logs Open, opened with Android Intents. UPDATE: no longer called on textinput's on_text, only on Logs-tab text scrollview height (from .kv) and Logs-tab text sets (from this .py). HACK: there is no documented way to make a TextInput scroll horizontally when [do_wrap: False]. The TI scrolls only vertically by itself, and its width must be set to enable horiz scrolls when wrapped in a ScrollView. But there seems no way to do this short of the following brittle code; WHY? ALSO must run after phone rotation, else hscroll lost: Scrollview.on_height. ---- UPDATE: this code now also tries to works around tabs bar rarely not drawing after a phone rotate to landscape on Android 12L. This failed in on_start(), which docs it further; since code here is called on rotates too (but only for the Logs tab: text-scroll size), do tabs-bar redraw here. This is overkill in calls for text sets, but might work, and is harmless and unnoticeable. UPDATE: PUNT - this didn't apply to Help/About/Config tabs, and didn't help for the Logs tab with either coding below; and it's too rare to fret... UPDATE [1.1.0]: a variation of this was also tried--and also failed--in the .kv file's code, to Clock.schedule a do_layout() for the TabbedPanel from GridLayout, or ask_update() for the whole root display, on panel width changes on Android (which catch phone rotations). Is this an SDL2 glitch? UPDATE [1.1.0]: yes, it is - this reflects a bug in SLD2, Kivy, or Samsung Android, which drops the event having correct screen size sans Android 12L+ taskbar. See the window section of App.on_start() ahead for more info. The app can't work around this, because no correct display size exists. UPDATE [1.1.0]: after globally converting absolute to display-scaled pixels using dp(), an absolute 50 for padding here was not enough on phones, and longest lines were truncated on the right. This seems to be because the new [dp(12), dp(12)] padding in the .kv when scaled was > the old absolute 50 here. The padding's former absolute [24, 24] jived with absolute 50, and the size setting here must manually include the .kv's padding (the 50 wasn't an rpad fudge factor, it was to accomodate all horizontal padding). Changing to dp(25) here foo fixed the truncation on all devices, without leaving space on the right. LESSON: once you go scaled, you must go all the way!... -------------------------------------------------------------------- """ trace('In text hscroll enabler') widthcalc = self.ids.logfilescroll.width for linelabel in self.ids.logfiletext._lines_labels: widthcalc = max(widthcalc, linelabel.width + kivy.metrics.dp(25)) # 50=pad self.ids.logfiletext.width = widthcalc # [1.1.0] # work around tabs bar rarely not drawing after rotate ~landscape ##self.ids.toptabs.canvas.ask_update() ##self.canvas.ask_update() # whole screen """ DEFUNCT - now in-tab def do_logfile_path(self): picker = LogfilePickDialog( pickstart=str(self.logfile_path), onpick=self.do_log_path_pick, oncancel=self.dismiss_popup) self._popups.push(Popup(title='Choose Logfile to View', content=picker, auto_dismiss=False, # ignore taps outside size_hint=(0.9, 0.9))) self._popups.open() def do_log_path_pick(self, logfilepath, popupcontent): "" On Pick, close the picker dialog, and spawn an Android intent to view the logfile. TBD: could just scroll the file's text in this tab, but Kivy TextInput might hang for large files. "" if not os.path.isfile(popuppath): self.info_message('Logfile path is not a file.') return else: # TBD: or try showing in a kivy textinput widget? # TBD: intent seems same as webbrowser.open()/open_web_page()? """ #@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ # APP CLASS #@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ class PCPhoneUSBSync(App): """ ======================================================= Auto builds Main root widget in ./pcphoneusbsync.kv. Locates file by class name (alt: pass as a string). Could be built manually, but avoids add_widget, etc. Or manual + build(), or string + Builder.load_string(). Subtlety: 'root' refers to the top-level Main() widget, event though it's built auto from .kv file, not here. ======================================================= """ # bound to Widget font_size in .kv: changes update ALL widgets dynamic_font_size = NumericProperty(FONT_SIZE_DEFAULT) # set before build() """UNUSED Works, but not required: def build(self): self.dynamic_font_size = FONT_SIZE_DEFAULT return super().build() UNUSED""" """UNUSED Does not auto recenter, whether set size before or after super().build(), or in class def before App.build() is ever called; order also doesn't impact the momentary resize twitch in the gui, and doing this at the top of this script doesn't either; see startup_gui_and_config_tweaks(); def build(self): result = super().build() if RunningOnWindows or RunningOnLinux: lx = 150 if RunningOnLinux else 0 # was 100 (oct23) Window.size = (kivy.metrics.dp(748+lx), kivy.metrics.dp(612+lx)) # (w, h), [1.1.0]+ return result #return super().build() if RunningOnWindows or RunningOnLinux: lx = 150 if RunningOnLinux else 0 # was 100 (oct23) Window.size = (kivy.metrics.dp(748+lx), kivy.metrics.dp(612+lx)) # (w, h), [1.1.0]+ UNUSED""" def on_start(self): """ Run by kivy .run() after intitialization - after build() has built the GUI, but before app has started running. """ global guiroot super().on_start() root = self.root # GUI root: for attrs in Main() guiroot = root # export to global for trace() # once a global, now an attr root.script_running = NOTHING_RUNNING # divide runs in logcat, requires script_running trace('@' * 80) trace('app.on_start') # allow Popups to nest root._popups = _PopupStack() # SPLASH # close splash for Windows and Linux frozen executables now: window open; # must do this _before_ missing-files error popup, else splash covers it! if not RunningOnAndroid: close_pc_splash_screen() # VITALS # warn the user if supporting runtime files are absent: # likely moved exe out of unzip folder (in docs, but...) vitals = (HELP_FILE, ABOUT_FILE, TERMS_OF_USE_FILE) vitals+= (MERGEALL_PATH, 'usbsync-anim.gif') # not .png vitals+= ('usbsync-pc',) if not RunningOnAndroid else () if any(not osexists(v) for v in vitals): root.info_message( 'Fatal error: missing app files.' '\n\n' 'This app cannot run because it cannot find one ' 'or more of its supporting files. Did you move the ' 'executable without its folder? That doesn\'t work.' '\n\n' 'The app will close itself to avoid exceptions. Please ' 'see the User Guide\'s App-Packages coverage for more info.', usetoast=False, nextop=self.shutdown_app) return # show message and die on Okay # ICONS # pcs seem to need help on these beyond pyinstaller build # the .ico doesn't work on Windows with Kivy (cause unknown) if not RunningOnAndroid: self.title = APPNAME # no pc trial pciconname = ('icon-round.png' if RunningOnWindows else # not .ico 'icon-round.icns' if RunningOnMacOS else 'icon-round.gif') pciconpath = osjoin('usbsync-pc', pciconname) self.icon = pciconpath # rounded-corners version Window.icon = pciconpath # else kivy icon displayed # PRELIMS # preset configs+paths root.load_persisted_settings() # tweak gui+configs for platforms, etc. root.startup_gui_and_config_tweaks() # fetch+update run counter: macos perms, terms of use, trial version # [1.1.0]+ (oct23) catch permission errors: e.g., Windows unzips to # C:\Program Files; could specialize msg per platform, but very rare; # don't continue on excs, else may always repeat startup message; try: runnum = self.get_run_counter(root) except: root.info_message( 'Fatal error: cannot write admin files.' '\n\n' 'This app cannot run because it does not have permission ' 'to write its admin files. On Windows, this is often caused ' 'by unzipping the package in C:\\Program Files; please use ' 'any other location. In other cases, please ensure that the ' 'app\'s data folder is available and allows reads and writes.' '\n\n' 'The app will close itself to avoid exceptions. Please ' 'see the User Guide\'s App-Packages coverage for more info.', usetoast=False, nextop=self.shutdown_app) return # show message and die on Okay # PERMISSIONS # ask user to enable macOS full disk access on first run only if RunningOnMacOS and runnum == 1: root.macos_ask_for_full_disk_access() # verify|request on each Android startup: all files access 11+, else old style if RunningOnAndroid: root.get_storage_permission() # everything past this may not (yet) have storage permission (nonmodal # system dialogs); check/ask again on folder-chooser popups and restarts # TBD: ask for battery-optimzatin disable here (see wake locs ahead)? # LOGS, UNDO # fails here for pre-11 android: runs before permission dialog dismissed # root.init_logfile_folder() # init latest logs now for tab switches root.latest_logs = None # trigger first load on first Logs tab view # initially allowed, till sync sans bkp or undo sans __undoable__ root.undo_verboten = False # TBD: make __undoable__ in TO/__bkp__s if countruns==1 (first run)? # but can't know if true: pre-app syncs may or may not have saved bkps # HELP+ABOUT # load terms-of-use file, for About and popup tou_file = open(TERMS_OF_USE_FILE, 'r', encoding='utf8') # [1.3.0] mods tou_text = tou_file.read() tou_file.close() # fill text displayed in tabs with open(HELP_FILE, 'r', encoding='utf8') as help_file: # [1.3.0] mods root.stage_help_message = help_file.read() with open(ABOUT_FILE, 'r', encoding='utf8') as about_file: # [1.3.0] mods # because it's easy to forget to change the file trialnote = ('' if not TRIAL_VERSION else '\nFree trial: %d of %d opens\n' % (runnum, TRIAL_APP_OPENS)) platname = ('Android' if RunningOnAndroid else 'Windows' if RunningOnWindows else # no cigwin: a windows exe 'macOS' if RunningOnMacOS else 'Linux' if RunningOnLinux else '(Unknown)') """ # no: this is runtime, not buildtime!... monthyear = datetime.datetime.now().strftime('%B %Y') # or .date.today() copyright = '2023' dateparts = monthyear.split() if len(dateparts) == 2 and dateparts[1] != '2023': copyright = '2023-%s' % dateparts[1] """ """ # no: these are both now from new PUBDATE at top of file [1.1.0] # change me manually on rebuilds (or mod build scripts?) monthyear = 'May 2023' copyright = '2023' """ # add underlines to uppers tou_text2 = ''.join([line + '\n' if not line.isupper() else line + '\n' + '=' * len(line) + '\n' for line in tou_text.splitlines()]) # not yet seen, but... androidpower = ('' if not RunningOnAndroid else '\n\nANDROID NOTE\n' '============\n\n' 'Some phones kill apps to save battery power more ' 'aggressively than they perhaps should. This app runs its ' 'long-running actions as Android foreground services by default ' 'to avoid this risk, and no automatic kills have been seen or ' 'reported, even for multi-hour runs and very large content. ' 'Should you ever see an action killed anyhow, please disable ' 'battery optimizations for this app in your Apps Settings. ' 'This may also avoid temporary app stops on some phones.\n') # [1.1.0] about_message = (about_file.read() % dict(VERSION = VERSION, PUBDATE = PUBDATE, # [1.1.0] PLATFORM = platname, COPYYEAR = PUBDATE.split()[1], # [1.1.0] TRIALNOTE = trialnote, # [1.3.0] now unused TERMSOFUSE = tou_text2, ANDROIDPWR = androidpower)) root.stage_about_message = about_message # DEFUNCT # kivy TextInput is wildly slow (pauses everywhere, Help ~8secs on Windows) # (no effect) predraw Help tab to avoid half-second resize on first open? # root.ids.helptab.canvas.ask_update() # (also no effect) with or without a kivy property (and scrolls poor) punt! # root.ids.helptext.text = root.help_message # FIXED: lag was eliminated by scheduling text set => see on_tab_switch() # WINDOW+WAKELOCK # on android, pan iff needed so input target is above onscreen keyboard; # default '' = don't pan or resize; also 'pan' (always), 'resize' (no-op) Window.softinput_mode = 'below_target' # catch Back button: route user to Main tab, or close if in Main tab; # also ignore Back/Escape if any dialog open: Back too easy in android Window.bind(on_keyboard=self.catch_back_button) # warn user if tries to close app while Main action is running; # called on 'back' when app foreground, but NOT on recents swipe; Window.bind(on_request_close=self.on_request_close) #----------------------------------------------------------------------------- # [1.1.0] Android wake locks, 2.0 # # Summary: allow screen timeouts (and savers) to be toggled by the user. # This requires a manual partial wakelock on Android - p4a's screen-only # wakelock must be disabled, and didn't ensure cpu on screen-off anyhow. # # On Android, when "wakelock" is enabled in buildozer.spec, it enables # kivy=>buildozer=>p4a's wakelock, which is just SCREEN_BRIGHT_WAKE_LOCK. # This has has been deprecated since API level 15 (2011's Android 4!), # and only keeps the screen on while the app has focus (it's released # and required by p4a in on_pause/resume). This doesn't keep the CPU on # in sleep ("doze") state; is cancelled during screen off for power-button # presses; and prevents screen timeouts while in foreground, which can # drain battery if the app is left in foreground sans the power button # and user Settings push screen-off out for a long time. # # Ref: https://developer.android.com/reference/android/os/ # PowerManager#SCREEN_BRIGHT_WAKE_LOCK # # Code: # in kivy's p4a code on github, at pythonforandroid/bootstraps/sdl2/ # build/src/main/java/org/kivy/android/PythonActivity.java. # # In addition, kivy exposes a call in SDL2 for enabling/disabling # screensavers - which weirdly means screen timeouts on Android. It # defaults to off (disabled), and on Android uses FLAG_KEEP_SCREEN_ON, # which is an alternative that seems preferred in Android's docs. # On Android, this seems *WHOLLY REDUNDANT* with p4a's "wakelock". # Enabling either option keeps the screen on; both disable timeouts; # and neither ensure that the CPU will keep running when the screen # is turned off (if they do, it's undocumented and buried deep in # Android's source code). The only diff: sdl2's flavor is switchable # and also applies to PC screensavers; p4a's is fixed and Android only. # # Proabably, forcibly keeping the screen with either scheme ensures # CPU by preventing the device from going into sleep mode (Android's # "doze" circus). But this is just a side-effect; won't work as soon # as the user presses the power button, or switches to another app # that allows the screen to timeout; and drains the user's battery # badly - and needlessly vs partial wake locks. # # Code: # in sdl2's code on github, at: SDL/android-project/app/src/main/ # java/org/libsdl/app/SDLActivity.java # # That said, actions in testing finish with the screen off in both # thread and service mode, and Android's docs on this are pathetic # See "doze" and "lightweight doze"; sleep is as opaque as it can be # (and may even vary by device!), and battery-optimization stops make # it worse. This story has grown convoluted over Android's history, # and it's unclear how wakelocks play with doze, foreground services, # and spawned threads and processes. But wakelocks probably don't hurt. # # FOR 1.1.0: turn off p4a's keep-screen-on wakelock in builds; allow # sdl2's alternative to be enabed/disabled with a toggle in Confgs tab; # and manually acquire/release an Android PARTIAL WAKE LOCK (PWL) via # pyjnius when actions start/exit. This reinstates screen timeouts # on demand, and PWL is the only API tool documented to do anything for # CPU usage directly. The wakelock is held only while and action runs, # but is not released in on_pause cecause it must appply to app switches # too. This requires create/acquire/release code in .py (here), plus # manifest permission in buildozer.spec; "wakelock" in buildozer.spec # must be disabled else screen-on is constant and unswitchable. # # Ref: https://developer.android.com/training/scheduling/. # Ref: https://developer.android.com/reference/android/os/ # PowerManager#PARTIAL_WAKE_LOCK # # There remains battery-optimization kill+stop concerns. Running a # foreground service elevates priority to make kills unlikely, but # stops are more gray. This might be addressed by running an intent # ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS to direct users to # disable power optimizations, but this has Play-store consequences # that are unclear and undocumented (yes, a pattern), and we don't # need the drama. For now, a note in the About tab and other docs # will have to suffice; TBD. #----------------------------------------------------------------------------- # init in startup_gui_and_config_tweaks() from persisted settings # Window.allow_screensaver = True # True = PC screensaver + Android screen timout if RunningOnAndroid: # create a wake lock to use later [1.1.0] activity = root.get_android_activity() Context = autoclass('android.content.Context') PowerManager = autoclass('android.os.PowerManager') # works on both activity and context (!) powermgr = activity.getSystemService(Context.POWER_SERVICE) wakelock = powermgr.newWakeLock( PowerManager.PARTIAL_WAKE_LOCK, 'usbsync%s::actionrunning' % ('trial' if TRIAL_VERSION else '')) root.wakelock = wakelock # and .aquire()/.release() around action runs #----------------------------------------------------------------------------- # [1.1.0] Rotate glitch, reloaded (docs only - cannot work around) # # Try to work around ya kivy bug: on an Android 12L fold4, the tabs bar # is occasionally not redrawn when rotating to landscape; force a redraw? # Alas, Window on_rotate() never fires; try in enable hscroll, then etc... # # Fail: Window.bind(on_rotate=lambda: self.root.ids.toptabs.canvas.ask_update()) # Fail: Window.on_rotate = lambda rotation: print('window on_rotate') # Fail: self.root_window.update_viewport(); also tried code in .kv file # # LATER [1.1.0] findings (and punt: can't change sdl2 events sans more info): # the event catchers below often get fired twice when going to landscape # WHEN IT WORKS: the event fires first for full screen size, and then again # for full sceen size minus Android 12L taskbar size. But it fires just # once for full size WHEN TABS BAR IS MIA; it never fires twice when going # to portrait; and often fires just once with correct size for landscape. # Either SDL2 or Kivy is dropping correct-display-size events, or Android # (or Samsung's flavor of it) sends them badly. Keyboard opens don't fix # this because no correct size (sans Android 12L+ taskbar) ever exists. # The app cannot work around this for the same reason: display size wrong. """ From logcat: # okay: tabs bar drawn in landscape (with visible glitch) (PPUS) window on_resize, (PPUS) sizes: (2176, 1749) [2176, 1749] (PPUS) window on_resize, (PPUS) sizes: (2176, 1623) [2176, 1623] (PPUS) window redraw (PPUS) window redraw # okay: tabs bar drawn in portrait (PPUS) window on_resize, (PPUS) sizes: (1812, 1968) [1812, 1968] (PPUS) window redraw # ==>FAIL<==: tabs bar missing in landscape (PPUS) window on_resize, (PPUS) sizes: (2176, 1749) [2176, 1749] (PPUS) window redraw # okay: tabs bar drawn in portrait (PPUS) window on_resize, (PPUS) sizes: (1812, 1968) [1812, 1968] (PPUS) window redraw: (1812, 1968) # okay: tabs bar drawn in landscape (PPUS) window on_resize, (PPUS) sizes: (2176, 1623) [2176, 1623] (PPUS) window redraw: (2176, 1623) """ # Event-catcher alts: # works, but futile: system_size wrong due to dropped events """ def force_redraw_in_two_seconds(): trace('sizes:', self.root_window.size, self.root_window.system_size) if True or RunningOnAndroid: Clock.schedule_once( lambda dt: (trace('window redraw:', self.root_window.size), self.root_window.update_viewport()), 2.0) """ # possibly overkill: fires for PC height changes too """ def window_resize(width, height): trace('window on_resize', end=', ') real_resize(width, height) force_redraw_in_two_seconds() real_resize = self.root_window.on_resize Window.on_resize = window_resize """ # occasionally fires twice for android rotates - but see above """ def window_width(*args): trace('window width', end=', ') force_redraw_in_two_seconds() Window.bind(width=window_width) """ #----------------------------------------------------------------------------- # TERMS OF USE # popup terms-of-use blurb on first run (also added to About tab above); # on all platforms; onerous, but makers need to protect themselves too; # popup ~last so appears on top (not displayed if trial counter at max); if runnum == 1: root.info_message('Welcome to %s. Before you get started, please ' 'take a moment to read and agree to the following.' '\n\n\n' '%s' '\n\n' 'For reference, you can find a copy of all these ' 'statements in the app\'s About tab.' % (APPNAME_FULL_OR_TRIAL, tou_text), usetoast=False) # TRIAL # check for max-runs expired in full-but-trial version of app (no ads!!) # truly last, so android perms dialog won't cover (run #1 popups never do) if RunningOnAndroid: self.handle_trial_counter(runnum, root) """DEAD # get mergeall's config module for maxbkps checks+changes (code+object) # value imported here may be trounced by the value loaded into Config # this is complicated: see root.update_mergeall_configs_maxbackups() # UPDATE: must mod backups, not mergeall_configs - backups uses "from" # see update_mergeall_configs_maxbackups(), now run at SYNC time only os.chdir('mergeall') # in app-install (unzip) folder sys.path.append(os.getcwd()) # .pyc not text, '.' fails here trace('ma=>', os.getcwd()) trace('ma=>', os.listdir(os.getcwd())) import mergeall_configs # threads run in subfolder, same process assert hasattr(mergeall_configs, 'MAXBACKUPS') # services run in separate process root.mergeall_configs = mergeall_configs os.chdir('..') sys.path.pop() DEAD""" def check_for_running_service(self): """ -------------------------------------------------------------------- [NO LONGER USED; ref in startup_gui_and_config_tweaks()] On restarts, detect running service and reset in-progress state, else user could start a new action, which breaks the actions model and yields odd states. The alternative is to kill a service along with the app, but this doesn't seem very well (if at all) suported in kivy/p4a today (see on_request_close()). Threads die with the app, and need no in-progress check. Services die only on Recents upswipe. Nit: getRunningServices() is marked as deprecated in Android docs, but says it will stick around for same-app use. If it ever goes away, a sentinel file may work just as well, sans unexpected kills by the o/s. A field in the service class may work too (though it's java, and ditto on kills). UPDATE: PUNT - this works on all Androids supported, but it is based on a deprecated API call that may go away any time, and is inherently inaccurate and error prone: the service might exit in the time between the check here and the restore of GUI state and message receiver, leaving the app waiting for the end of a service that's gone. Though rare, the app could go in-progress forever, requiring restart; ugly, that. A service-breadcrumb file may address API deprecation, but wouldn't be accurate if the service was foribly killed by Android, and suffers from the same timing issue as the API. TO DO BETTER, the Back button handler now prohibits app close if a Main action (service or thread) is in progress. This is applied for all Main actions, not just update actions: show and diff are okay to kill, but would still require in-progress detection on restarts. This policy is also better for threads, which are killed outright on app close. Users can still kill the app along with its running action by an upswipe in Recents; this cannot be intercepted in kivy/p4a (afaik), but seems a reasonable last-resort op. -------------------------------------------------------------------- """ """ In java: private boolean isMyServiceRunning(Class<?> serviceClass) { ActivityManager manager = (ActivityManager) getSystemService(Context.ACTIVITY_SERVICE); for (RunningServiceInfo service : manager.getRunningServices(Integer.MAX_VALUE)) { if (serviceClass.getName().equals(service.service.getClassName())) { return true; } } return false; } """ """DEFUNCT root = self.root activity = root.get_android_activity() context = root.get_android_context(activity) appsuffix = 'usbsync' if not TRIAL_VERSION else 'usbsynctrial' svcname = 'com.quixotely.%s.ServiceRunscript' % appsuffix manager = activity.getSystemService(context.ACTIVITY_SERVICE) services = manager.getRunningServices(2 ** 31 - 1) for i in range(services.size()): service = services.get(i) if service.service.getClassName() == svcname: svcrunning = True break else: svcrunning = False if svcrunning: # reset gui, restart msg receiver trace('service still running') # read service breadcrumb file in app-install folder svcrecord = open(SERVICE_BREADCRUMB, 'rb') opname, cmdargs = pickle.load(svcrecord) svcrecord.close() if opname != 'UNDO': postop = lambda: None else: # restore postscr callback for undos # assume undo all conditions still hold latestbkp = cmdargs[0] postop = lambda: root.discard_latest_backup_folder(latestbkp) root.service_in_progress_state(opname, ?, postop) # and hope didn't just stop! DEFUNCT""" def catch_back_button(self, window, key, scancode, codpoint, modifier): """ -------------------------------------------------------------------- On keypress 27 - Escape, which also means Back button on Android - either goto the Main tab automatically, or allow Back to close the app if it's already on the Main tab. This is a bit custom, but tabs feel a lot like windows, and it's otherwise too easy to exit app unintentionally. This might also force pause mode in Main, but convoluted. UPDATE: on Android, this now also prohibits app kills if any Main action is running as either a service or a thread. Users can still kill both forcibly by Recents swipes instead (which seems uncatchable in the kivy/p4a realm), but a Back app kill would kill a thread outright, and service-running detection on restarts is deprecated + inaccurate (see above). Related: it might be nice to change tabs on SWIPE gestures automatically, but they are far too low-level to integrate: https://duckduckgo.com/?q=kivy+change+screen+on+swipe+gesture Kivy's a powerful framework, but could use more docs and dev. UPDATE: on Android (Back) and PCs (Escape), now ignores the event if any modal dialog is open, rather than going to Main or closing the app. Back is way too easy to hit on Android. This jives with recent auto_dismiss=False mod in all popups. -------------------------------------------------------------------- """ if key != 27: return False # continue processing key else: trace('back button press') root = self.root if not root._popups.empty(): # ignore if any modal dialog open return True # end key processing elif root.ids.toptabs.current_tab != root.ids.maintab: # switch to Main tab and end key processing root.ids.toptabs.switch_to(root.ids.maintab, do_scroll=True) return True else: # Back in Main tab if not root.script_running: # close now - sans hanging! self.shutdown_app() else: # prohibit close, on Android or PC message = ( # [1.1.0] this seems TMI in hindsight, and didn't say # what is and is not safe to close: rewrite in full; # #'This app cannot be closed while a Main-tab ' #'action is running.' #'\n\n' ' #'This ensures your content\'s safety. An app close ' #'would kill the action outright if running as a ' #'thread, and it\'s impossible for restarts to ' #'accurately detect in-progress services. In both ' #'cases, content updates might fail.' 'You are trying to close this app while a Main-tab ' 'action is running.' '\n\n' 'It\'s safe to close this app while a non-update SHOW ' 'or DIFF is running, but closing while any other ' 'actions are running may stop updates in progress ' 'perilously and require action reruns.' '\n\n' 'If you really want to kill both this app and its ' 'running action, please instead use either a swipe ' 'in your Recents display on Android, or the window ' 'close button on PCs.') root.info_message(message, usetoast=False) return True # end key processing def shutdown_app(self, popupcontent=None): """ The quest for a hang-free app close on Android. Outside PCs, now used by the Back handler above. See on_request+close below for the full saga. popupcontent is optional and ignored: may be called from a popup or not, but this is shutdown, right? """ trace('os._exit: bye') os._exit(0) # this feels wrong, but it works... """ hangware... trace('app.stop') self.stop() # end the Android activity (sort of) trace('window.close') Window.close() # just in case (same if on_stop); reached trace('bye') # doesn't reach this on a hang... """ def on_request_close(self, *args, **kargs): """ -------------------------------------------------------------------- Warn user if tries to close app while Main action is running. Called on 'back' when app foreground, but NOT on recents swipe. HANGS: kivy closes were also seen to hang sporadically in initial Android app dev, with no useful info in the log ("Leaving application in progress..." was displayed, but the GUI didn't close). As a workaround, force the close with either self.stop(), sys.exit(), or os._exit(). These may dork up kivy state (and sys.exit may be caught/disabled by kivy), but the app run is over anyhow. The atexit module's handlers are not run for os._exit, so this seems the last resort. ---- UPDATE: on Android, self.stop() ends the Android activity, and seems to have fixed the issue. Still tbd: nothing can be done if a Recents swipe closes hang (short of kivy code spelunking...). UPDATE: back-button exits have been seen to hang again, but it may be due to low-memory on phone, and a reset cleared this up. Just in case, add a Window.close() after App.stop() - per the web, this seems to have been a perennial issue in kivy sans framework fix... ---- NOPE: still hanging sporadically, even after reset, and even without running any actions. Fallback to the nuclear os_exit() option; which is probably bad, but closes are a convoluted mess, routed though kivy, p4a, sdl2, and java bootstrap code. os._exit() skips the drama... Known downside: os._exit precludes shrink-back-into-app-screen-icon animation, though hangs are worse, and this anim only fires if never navigate away to another app (else the app screen is no longer under the app for the animation; Android state is wack with poobrain). NEVERMIND: no other p4a or buildozer app built does this animation... NOTE: for all exits tried here, a foreground service will keep running if the app is killed by a Back button; the required manifest code to prevent this seems unsupported, though an intent might work (tdb); https://duckduckgo.com/?t=ffab&q=android+kill+foreground+service ---- UPDATE: this will now be used on PCs only, when a GUI close request is triggered by the user. On Android, Back-button closes are now prohibited during Main action runs (above), and never reach this code. Curiously, this is still invoked on Android for Recents-swipe closes, which trigger on_pause, on_resume, this, and on_stop when a service is running (and the first three of these with no service - sometimes), but cannot be disabled by a True return here in any event. App close in kivy/p4a could be a bit more coherent than it currently seems. ---- NOTE [1.1.0]: after thousands of runs, a source-code run launched by PyEdit was seen to hang for ~6 seconds on close-button press, when a chooser popup was open, on macOS, after PyEdit itself was killed, and when the GUI was allowed to remain minimized for days. The hang was due to Kivy logger error and looping: it filled a 3.7M and 92k-line logfile with ""[WARNING] stderr: --- Logging error ---" mostly (after "[INFO ] Base: Leaving application in progress...") before the app finally closed. This hang is harmless and cannot be recreated (and thus was never verified as fixed), but it's a puzzler. Logging might be disabled everywhere with KIVY_NO_FILELOG and/or KIVY_NO_CONSOLELOG (see top of file), but they're useful on errors. Though now 5 months and many details ago, the comment near the top of this docstring strongly suggests that a logger loop was not the cause of earlier, initial Kivy hangs on Android which elicited the os._exit() nuclear option. Then again, Kivy is wonky business... ALSO NOTE: in the possibly related column, the try_any_nohang() timeout tool now sets daemon to True in the threads it spawns, so they will not delay app exit (they formerly inherited False). This may matter if these threads hang forever, but it's unikely; the False still shouldn't have sent the Kivy logger into overdrive; and the Main-tab action thread initially used on Android was already using daemon=True and hence should have exited on action finish. -------------------------------------------------------------------- """ trace('app request close') root = self.root # GUI root: Main() attrs if RunningOnAndroid: # not reached on Back (intercepted above) return False # but is reached for Recents swipe (oddly!) if not root.script_running: # pick your closer... #return False # do normal kivy close - hangs sporadically #self.stop() # end the Android activity (really) #sys.exit() # nuclear option: exit now (really!) #os._exit(0) # nuclear option: exit now (really!!) self.shutdown_app() else: message = ( # [1.1.0] never get here anymore if RunningOnAndroid, # and a service is auto killed on android swipes anyhow; # also note the show and diff are okay to close early # here and for android above (could be detected auto); # #'Unless the action is running as an Android service with ' #'notifications, ' 'You are trying to close this app while a Main-tab ' 'action is running.' '\n\n' 'Non-update actions SHOW and DIFF can be stopped safely. ' 'For all other actions, any updates in progress will be ' 'stopped perilously and may require action reruns.' '\n\n\n' 'Continue with close?') confirmer = ConfirmDialog(message=message, onyes=self.shutdown_app, # trigger normal close onno=root.dismiss_popup) # don't close app root._popups.push(Popup(title='Confirm App Close', content=confirmer, auto_dismiss=False, # ignore taps outside size_hint=(0.9, 0.8))) # wide for phones? (moot) root._popups.open() return True # don't close the window (on PCs, at least) def get_run_counter(self, root): """ -------------------------------------------------------------------- Read+update the run-cunter file. Was in handle_trial_counter(), but this is now used on all platforms, and not just for the Android trial version: also used to prompt on macOS for perms, and show terms of use on run #1 everywhere. Also refactored to ensure trial-expired is the _last_ on_start() popup created, so it's not covered by others: the macOS and t-o-u popups won't cover it because they appear only in run #1 (and the trial expire is later), but the Android storage perms popup may appear too if the user never granted permissions. Since there's no way out of trial-expired, perm dialog is moot. UPDATE [1.1.0]+ (oct23): the write() here can easily fail if users unzip the Windows package in C:\Program Files. Catch this at call and issue a popup with tips, and close. User should unzip anywhere else; permission mods (once) and run-as-admin (always) are harder. This was per an Oct23 user report; the same user was also trying to sync over MTP, and both issues merited Tips in App-Packages. The write() may fail for other reasons too, but it's wildly rare. -------------------------------------------------------------------- """ # get run counter on all plaforms apppriv = root.storage_path_app_private() or '' countpath = osjoin(apppriv, RUNCOUNT_FILE) if not osexists(countpath): countruns = 1 else: countfile = open(countpath, 'r') # in app-private, not install countruns = int(countfile.read()) + 1 countfile.close() # save new run counter on all platforms countfile = open(countpath, 'w') countfile.write(str(countruns)) countfile.close() trace('#Runs:', countruns) return countruns # for mac perms, terms of use, trial version def handle_trial_counter(self, countruns, root): """ -------------------------------------------------------------------- Update run counter: may be used for trial version (not ads or feature limits!). Counter _may_ begin at 2 on android, after all-file-access dance (but hasn't since early codings). This scheme has evolved; skip ahead to CONCLUSION for the outcome. INITIAL: yes, this scheme can be subverted by simply uninstalling and reinstalling the app every N runs (the counter is in app-private which is wiped), but if people want to work that hard just to avoid paying $2.99, we really don't want to take money from them anyhow. Also okay: a sideloaded trial version on website, and/or just an annoying popup sans full close. This may wind up free, and won't net much money given Android's ecosystem (free or ignored, Google takes 15%, and $5 is expensive). Let's not be douchey here, eh? ---- UPDATE: a sideloaded trial version on website is not okay - a simple remame+unzip+untar exposes the full source code, and, alas, this is not a domain where trust makes a whole lot of sense. UPDATE: app-private storage may be wiped by an uninstall+reinstall, but always is by 'Clear app data' in Settings=>Apps=>App=>Storage. This seems a misfeature (it zaps settings, preferences, and more in lots of apps), and will erase this app's run-counter file. This is still inconvenient after every N runs, but easier than a reinstall. To close this loophole, store the run counter in '.' (CWD), which is the app's install folder, not its wipeable app-private space. If this doesn't work, a '.ppus' hidden file might be used as a doppleganger in app-specific ("external"), which is wiped only on app uninstall. CWD seems less intrusive, and is already known to be writeable for changes to mergeall_configs.py. ---- UPDATE: nope... app install folder is wiped of additions too, on a simple clear-app-data (google's security obsession is not right). Fall back on checking for a redundant .hiddenfile in the app-specific storage folder if the counter is missing in app-private. app-spec is cleared on unistalls too (sans the keep-data toggle on 10+), but not on a user clear-app-data (?), and is not intrusive - users can store content there, but it's likely to become inaccessible soon. Clear-app-data also resets the nested mergeall/mergeall_configs.py, which means it's not just top-level, and a nested file won't subvert it. By contrast (and INCONSISTENTLY!), both app update/reinstall and app uninstall with keep-app-data clicked do not wipe either app private/install folders or mergeall_configs.py mods. Yes, really. And any solution that relies on Google's cloud for auto backups and requires users to be signed in to a Google account is right out! See update_mergeall_configs_maxbackups() for a related fiasco. ---- UPDATE: nope... app-specific ("external") storage _is_ cleared on Settings' clear-app-data too. It's also cleared on app uninstall, but only if users don't click the 10+ option to retain app storage. Clear-app-data has no such options. The uninstall toggle must be meant only for uninstall/reinstall sequences, though it's not clear why clear-app-data exists at all; why not just uninstall? So: the only remaining options are shared storage (which is indeed intrusive), or network/cloud schemes which are nonstarters here. CONCLUSION: Settings' clear-app data wipes app-private, app-install, and app-specific; unsintall retains all if insstructed to do so only. The only remaining options are shared storage (which is indeed intrusive), or network/cloud schemes which are nonstarters here. So: this app went with a full trial version with a limited number runs, implemented with a run counter in app-private storage - as initially. This can be subverted by both Settings' clear-app-data and (less easily) app reinstall, but both are annoying enough to serve as incentives to buy the full app. The popup nag is just a polite request for reimbursment. Decent users will do so; we can't help people who are just cheap; and we shouldn't take money from people who truly can't afford $2.99. Update: see get_run_counter(), split off from here because its scope is now broader, and order matters: trial popup opens last == on top. ---- UPDATE: after a year, the app may be made free after all, because Play is a payola, and its users demand apps for free. - Play: apps have no visibility without paying for promo ads - Users: apps won't be installed when seen unless they are free - Apps: there's no way to fund promo ads sans in-app revenue Because this app will not do in-app ads or subscriptions, and cannot do in-app purchases for tech reasons (unsupported in Kivy and too much pyjnius code), this is a game-ended for paid. The only way to increase installs is $0 (free) sales that are promoted with ads; that's a complete loss, and just not worth it. Alas, Google point-of-control model on Android+Play wins; today... -------------------------------------------------------------------- """ """UNUSED def _teststorages(): # NOT USED IN PRODUCTION apppriv = root.storage_path_app_private() or '' appinstall = os.getcwd() appspec = root.storage_path_app_specific() stores = (('a-s:', appspec), ('a-p:', apppriv), ('a-i:', appinstall)) for (name, path) in stores: trace(name, path) trace('===>', os.listdir(path)) sprobe = osjoin(appspec, RUNCOUNT_FILE) pprobe = osjoin(apppriv, RUNCOUNT_FILE) iprobe = osjoin(appinstall, RUNCOUNT_FILE) probes = (('pprobe', pprobe), ('iprobe', iprobe), ('sprobe', sprobe)) for (name, path) in probes: if not osexists(path): trace(name, 'absent') open(path, 'w').write('1') else: prior = open(path).read() trace(name, 'present:', prior) open(path, 'w').write(str(int(prior) + 1)) if False and RunningOnAndroid: _teststorages(); return UNUSED""" # # check for trial expired on android only and in trial version only; # no run1 popups (permission, terms-of-use) will cover this: #runs > 1 # and android permissions is opened first if user never granted perms; # if RunningOnAndroid and TRIAL_VERSION and countruns > TRIAL_APP_OPENS: message = ('Sorry, but you\'ve now opened this app as many times as ' 'this trial version allows.' '\n\n' 'To continue using this app, please visit its Play-store ' 'page with the link below to get its full non-trial version. ' 'The non-trial version works the same, but does not limit the ' 'number of opens (and has no "Trial" in its name or icon).' '\n\n' 'Thanks for trying this app, and thanks for your support.') trialended = TrialEndedDialog(message=message, oncancel=self.stop) root._popups.push(Popup(title='Trial Ended', content=trialended, # self.stop can be run here size_hint=(1, 1))) # may make auto_dismiss moot # alt: popup.bind(on_dismiss=lambda: True) root._popups.top().auto_dismiss = False # the Back btn kills app too root._popups.open() # now all: auto_dismiss=False # and there's no way out but app exit (with a possible visit to Play) #""" def on_pause(self): """ -------------------------------------------------------------------- On user leaving the running app by picking another in Recents or 12L taskbar. Not triggered when up-swipe to kill app in Recents (maybe: see note above). Foreground service keeps running, notification erased on exit. Thread keeps running, no notification ever posted. Service stopped only on Recents upswipe, not Back stop/kill. Don't stop self.breceiver if app gets messages while paused? TDB - docs say do this, but this seems a bit misleading. Can the message be missed if sent while app pause? YES, though toast doesn't appear except in the app. ALSO, there seems no ill effect for not stop/restarting. Polling for a signal file in '.' or app-private won't detect exit while paused, but will when resumed; there's really no reason for a service then aprt from notifications (threads run on pause too), though this seems ample cause. -------------------------------------------------------------------- """ trace('app.on_pause') if self.root.script_running == SERVICE_RUNNING: #self.root.breceiver.stop()? pass return True # True=pause, don't stop now (default? docs seem askew) def on_resume(self): """ -------------------------------------------------------------------- On user returning to the paused app by picking it in Recents or 12L taskbar. Not triggered if repick app in either Recents or Apps after killing it with a Back button in Main or Recents upswipe: on_start comes after both, with unpacking app msgs. Don't stop breceiver if app gets messages while paused? -------------------------------------------------------------------- """ trace('app.on_resume') if self.root.script_running == SERVICE_RUNNING: #self.root.breceiver.start()? pass def on_stop(self): trace('app.on_stop') #""" if __name__ == '__main__': PCPhoneUSBSync().run()