File: PC-Phone USB Sync/code/PC-Phone USB Sync--source/run_script_as_service.py
"""
===============================================================
Run a Python script in an Android ForegroundService that
automaticaly runs this file in an spawned processs, with
command-line arguments passed in a JSON string, and output
(including prints) redirected to a file.
This allows the app's long-running asks to keep running when
the app is left and paused; threads do too, but do not post
a notification while running. It's also a wildly proprietary
Android-only option that requires much Java-API support code.
Mergeall's scripts can be run directly by subprocess.Popen on
Android and will continue in the backgrouns (and they are, in
the original tkinter GUI), but using a thread or service
sidesteps the child-process killing potential in Android 12+.
Neither threads, services, nor processes pause when the user
leaves the app for another.
This is wrapper is an alternative to coding+importing main()
functions for all scripts' __main__ logic, and changing all
print()s to use a custom object.
This is also less brittle that the thread wrapper: while it
too resets global system items for script duration, these
resets affect only the spawned process, not the main app/GUI.
There were multiple dev hurdles here - with thin or no docs:
- Must add FOREGROUND_SERVICE to buildozer permissions, else
service starts but dies after a few seconds; why not auto?
- Broadcast receiver callback always gets two arguments;
noted on docs, but terse, and the next point muted this
- stdout/err in the service go nowhere: to debug, had to
forcibly write 'i am here' msgs to file on phone (deleted)
- Coding error muted by the prior point: SystemExit evades
Exception catch, which meant send-msg never called
- To send a msg with context in a service, must use service
name from manifest in autoclass (not org.kivy name), AND
.mService member of service class (not the usual .mActivty
of activity object); mentioned nowhere in p4a's broadcast
docs or online, and finding it required studying java code
- In the end, the message id worked as guessed and seems
arbitrary, but this was not documented anywhere either
- on_pause/on_resume start/stop of receiver seems moot
The service and broadcast support is really quite useful, but
if you want people to use p4a/buildozer, please doc better!
And please add support for FileProvider, now an Android staple.
===============================================================
"""
import sys, builtins, os, traceback, io, json, time
from jnius import autoclass, cast
from android.config import ACTIVITY_CLASS_NAME
DEBUGGING = False
def dump(msg=''):
"""
For prints to whatever stdout is at the time.
This doesn't seem to show up in logcat, for reasons unknown (and uncared).
Send debugging messages to an easily accessed file on the phone instead.
"""
if DEBUGGING:
#print(msg, flush=True)
log = open('/storage/emulated/0/Documents/PPUS-log.txt', 'a')
log.write(msg + '\n')
log.close()
class Flusher:
"""
Wrap an output file to auto-flush all writes, so that output
appears immediately in the file to be seen by file watchers.
Also handles bad Unicode characters in output lines.
Unlike the thread's flusher, the output file is reopened by
name here, and the on_eof callback is not present: because
this is a separate process, it can't be a callback in spawner.
Alt: force unbuffered mode in file via os calls; portable?
On write exceptions due to Unicode errors, retry with ascii()
to escape all non-ascii chars. Else, a bad character in a
filename can crash the run due to output-stream write errors,
even for UTF8. Seen when printing a filename in diffall on
Android: "'utf-8' codec can't encode character '\udccc'...:
surrogates not allowed" (though how this decoded from the
filesystem by os.listdir() on UTF8-based Android remains tbd).
ascii() also escapes '\n' and adds enclosing quotes. Here:
'A\udcccB\n' => 'A\\udcccB\n', length 9, prints A\udcccB<eoln>;
'ABñC\n' => 'AB\\xf1C\n', length 8, prints AB\xf1C<eoln>
[1.2.0] Note: mergeall.py calls isatty() to see if it should show
help text to a console, in the unlikely event of a command-line
error; okay here => isatty() is defined by proxy via __getattr__.
[1.5] Added, but later disabled, a no-op flush() to try to avoid
double OS flushes for the print() redefinition used when the
mergeall.py script is run as a service by a Windows or Linux exe.
This context is not possible, because these exes can never run
mergeall.py in service mode--only in thread mode, which already
ignores flush(). The final report in mergall.py may cause double
flushes, but it's too minor and trivial to warrant optinization.
See main.py's "[1.5] About print() and trace():" #4 for more.
"""
def __init__(self, filename):
self.fileobj = open(filename, 'w', encoding='utf8')
def write(self, text):
try:
self.fileobj.write(text) # even utf8 can fail
except UnicodeEncodeError: # retry as ascii+\escapes
try:
putback = '\n' if text[-1] == '\n' else ''
if putback: text = text[:-1]
self.fileobj.write(ascii(text)[1:-1] + putback)
except:
self.fileobj.write('<Cannot write output text>\n')
self.fileobj.flush() # to file for tailers
def close(self): # on script-code exit
self.fileobj.close() # final flush moot
"""
def flush(self): # [1.5] moot, per above
pass # already flushed by write()
"""
def __getattr__(self, attr): # all others: to file
return getattr(self.fileobj, attr) # mostly optional here
def run_script_service_wrapper(
mainfilepath, # path to the script file to run in thread (rel|abs)
argslist, # command-line arguments list for the script
stdouterr, # where to route stdout+stderr: a file name, not object
onexitid, # message id: signal script/process exit to app/GUI process
appname, # 'usbsync' or 'usbsynctrial': full and trail apps' suffixes
stdin=None, # a string to use to thread's stdin, '\n' for multiline
trace=True): # if True, display state there
"""
Run a script file's code in a process, with system globals reset.
This module's code runs in a ForegroundService, and a new process
automatically spawned by Android (by virtue of a single ":" prefixed
to its name; wildly ad-hoc, that!). It runs the Mergeall script
as an exec() string with gobal mods.
This allows long-running tasks to keep running on app leave/pause;
threads do too, but this additionally opens an Android notification.
Alternative to importing script's main function (in '.' or a package
folder), for scripts whose main logic isn't coded as a function.
A subprocess.Popen() process could work too (and does, in the
pydroid3 tkinter GUI), but is much more likely to be killed by
the android 'phantom' process killer in android12+. Formal
service processes are (presumably) largely immune to kills.
The thread wrapper's postop won't work here (a callable from the
spawning process is invalid in the spawned process), but is instead
wedged into the broadcastreceiver's callback in the app/gui process.
Both trace and chdir are unneeded (prints in the GUI process still go
to logcats, and changing directories here has no impact on the GUI
process), but trace dumps state to the spawned process's stderr.
stdouterr is automatic here: pass a filename for the action's logfile,
which is reopened and closed here. A file object from the GUI process
need not, and cannot, be passed to this process.
There's no need to save/restore global state here: the process ends
on script exit, and no state is shared with the main app/GUI process.
Script exit is signaled to the UI by an Android BroadcastReceiver,
which is supported in p4a. The app will receive this message both
when it is foreground (visible), and when paused - in which case
it displays its toast message when it's in the foreground again.
UPDATE: +appname - the java folder name of the generated service
class's code differs for the full and trial versions of this app!
The message id is the same in both apps; it's not a folder name
and assumed to pass in libs, and it's unlikely both are listening.
"""
def showstate():
dump(
'=' * 4 + '\n' +
os.getcwd() + '\n' +
str(sys.path) + '\n' +
str(os.listdir('.')) + '\n' +
'=' * 4)
if trace: showstate()
# reopen logfile here, just created by app/gui process
stdouterr = Flusher(stdouterr)
# where/what to run
mainfiledir, mainfile = os.path.split(mainfilepath)
mainfileabs = os.path.abspath(mainfiledir)
# change global state
builtins.__name__ = '__main__'
sys.argv = [mainfileabs] + argslist # command-line arguments
sys.stdout = stdouterr # prints, writes (or reset .print)
sys.stderr = stdouterr # catch/show exceptions in script
sys.path = [mainfileabs] + sys.path # module imports ('.' fails, moot?)
os.chdir(mainfiledir) # relative-path files (if any)
if stdin != None:
sys.stdin = io.StringIO(stdin) # provide canned script input()
# in this process: new pvm and state, scope moot
try:
pycode = open(mainfile, 'r', encoding='utf8').read()
globalscope = {}
exec(pycode, globalscope)
#testing: time.sleep(10)
except SystemExit:
# mergeall -report mode ends with sys.exit(0)
# if uncaught: finally+reraise - skips broadcast!
pass
except Exception:
# other exceptions in script code: to logfile
traceback.print_exc()
finally:
# exc or not, close logfile (eof not a signal here)
stdouterr.close()
signal_script_exit(onexitid, appname)
def signal_script_exit(messageid, appname):
"""
Broadcast script-exit to app/gui process.
Alt: timer loop in app.gui process to poll for signal file;
simpler and portable, but polling will pause along with the
app on Android (broadcsts are delivered in pause state too?).
UPDATE: +appname - java folder name differs for full/trial app!
"""
Intent = autoclass('android.content.Intent')
intent = Intent()
intent.setAction(messageid)
#intent.putExtra('data', 'Main action process exit') # not required in this use case
#intent.setComponent('com.quixotely.%s' % appname) # explicit: this app only/always
# nope - doesn't work in a service
#pythonact = autoclass(ACTIVITY_CLASS_NAME)
#mActivity = pythonact.mActivity
# service activity name from manifest - not the main app's!
pythonact = autoclass('com.quixotely.%s.ServiceRunscript' % appname)
mActivity = pythonact.mService
activity = cast('android.app.Activity', mActivity)
context = cast('android.content.Context', activity.getApplicationContext())
context.sendBroadcast(intent) # to BroadcastReceiver in app's gui process
if __name__ == '__main__':
dump('in service process')
# at caller: argument = json.dumps('...')
arguments_str = os.environ.get('PYTHON_SERVICE_ARGUMENT', '')
arguments_dct = json.loads(arguments_str)
# run the script's code in this process
run_script_service_wrapper(**arguments_dct)