...

// [PPUS 1.6.1]
import android.util.Log;
// import com.quixotely.usbsync.ServiceRunscript;    // subclass generated from Service.tmpl.java
// import android.content.pm.ServiceInfo;            // for failed attempts #3 and #9 ahead


public class PythonService extends Service implements Runnable {
    ...

    protected void doStartForeground(Bundle extras) {
        ...
	if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
            ...
        } else {
            ...
            // [PPUS 1.6.1] opt out of Android 12 10-second delay for notification 
            // host-version bifurcation alert: S == api level 31 == Android 12
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
                builder.setForegroundServiceBehavior(Notification.FOREGROUND_SERVICE_IMMEDIATE);
            }
            notification = builder.build();
        }
        ...
    }
    ...

    /*
    =====================================================================================
    [PPUS 1.6.1] Catch Android15+ 6-hour data-sync foreground-service timeouts.
    See main.py's service_in_progress_state() in the land of Python for more info.

    Add this code to file:
        .buildozer/android/platform/build-arm64-v8a_armeabi-v7a/dists/
                   usbsync/src/main/java/org/kivy/android/PythonService.java

    Also insert the imports above to the top of this file and the notification 
    patch to its doStartForeground().  Adding this code to ServiceRunScript.java 
    in dists's com/ may work too, but it's generated from one of 5 templates.

    This may be useful if source is ever run on Android.  For app builds, after 
    this has been inserted once, the .buildozer/ zip will retain the mod permanently.
    =====================================================================================
    */


    private static String LOGTAG = "PPUS-Java";                // statics required: no instance
    private static String timeoutBroadcastName = "unset";      // init else null

    public static void registerTimeoutBroadcastName(String name) {
        /* 
        ---------------------------------------------------------------------------
        Originally called by Python but no longer used

        This works via pyjnius if statics (there is no instance), but the class-
        level tBN attribute has been reset to "unset" later in onTimeout.  This 
        is most likely because, per the app's manifest, Android runs this class 
        in a new process ctreated for the service that differ's from the app's 
        process.  Hence, changes in the app have no effect in the service.  
        Reloads at timeout are also possible but unknown.
 
        Could save to an XML file via SharedPreferences, but that seems overkill:
        hardcode PPUS's broadcast name ahead for Intent - and abandon generality.
        See also run_script_as_service.py for more on the process model.
        ---------------------------------------------------------------------------
        */

        timeoutBroadcastName = name;
        Log.d(LOGTAG, "registerTimeoutBroadcastName: " + timeoutBroadcastName);   // ok but reset
    }


    @Override                                                 // @Override is optional
    public void onTimeout(int startId, int fgsType) {         // 'this' is usually optional
        /* 
        ---------------------------------------------------------------------------
        Called by Android on a 6-hour data-sync timeout per rolling 24 hours.

        The timeout occurs only on Android 15+ for apps targeting 15 or later.
        Per docs, moving the app from background to foreground resets the timer.  
        That makes timeouts unlikely, given that users would naturally check
        action status or results, but two timeout crashes have surfaced on Play.

        Also per docs, the app must shutdown the service via this method within
        a "few" (undocumented) seconds or it is killed.  Shut down the service 
        here, and trigger an info_message popup in the GUI by sending a broadcast 
        message that the app is registered to receive.  This same message is used
        for normal action exits, distinguished per its Intent's extra data.

        ----
        Why a phone restart is required

        This ran into an issue where the app's state was glitchy even after 
        catching the timeout, stopping the BroadcastReceiver, and trying to kill 
        the service with all the options below.  The service would sometimes live
        on in Developer options/Running Services, and one new action in the app 
        would work but yield an "app keeps stopping" popup, and a second action 
        would hang while waiting for an action-exit message that never appeared.

        In this state, the service process seems unstoppable or unstartable.  It's
        possible that Android is stuck in a timeout loop, despite app foregrounding 
        that should reset the timer; or is invalidly trying to restart the timed-out
        service on later action runs instead of beginning a new one, even though 
        it is not marked as 'sticky.'  

        Logcat messages were mixed: one suggested the restarts theory, but others
        for double FATAL crashes strongly implied that the service couldn't start 
        anew because the data-sync time limit was exhausted - despite moving 
        the app from/to foreground before the run (do timer resets even work?).  
        Text: "Caused by android.app.ForegroundServiceStartNotAllowedException: 
        Time limited already exhausted for foreground service type dataSync".

        The cause for this state is undocumented and might derive from either 
        broadcast messages, services, or notifications and may originate in p4a's 
        or Android's source code - but this is too deep a rabbit hole for an issue 
        that has impacted just 0.02% of the app's likely 10k active user base to 
        date (and just 0.003% of its 60k total installers so far).

        By comparison, a 2026 user of a 2018 phone with limited memory recently 
        generated three crashes in Python 3.9.9's bytecode optimizer, likely due
        to internal storage corruption; this app can only do so much.

        So: rather than crashing on timeouts, we now at least issue an informative
        popup that explains action resumes and tells users to restart their phone 
        to make app state usable again, and forcibly kill the app to reduce the 
        chances of user reruns before phone restarts.  This isn't as good as full
        app recovery, but it's an improvement over timeout crashes.

        ----
        Perspective 

        These timeouts are a poison pill for sync apps, but they are also wildly
        unlikely to impact this on-demand app in normal usage, due to both timer
        resets and practicality.  They're not worth the effort, and such annual 
        breakages in Android make supporting it tedious in general.

        An alternative suggested by Android docs, user-initiated data transfer 
        (UIDT) jobs, works on Android 14+ only, may be limited to network transfers
        only, and requires too much Java infrastructure code; FG services have 
        support in p4a for Python programmers and should just work.  Android also 
        has options for NFC and IR but nothing general for long tasks; how ad-hoc!

        https://
            developer.android.com/develop/background-work/background-tasks/uidt

        https://
            android-developers.googleblog.com/2024/09/
            google-maps-improved-download-reliability-user-initiated-data-transfer-api.html
        ---------------------------------------------------------------------------
        */


        Log.d(LOGTAG, "Enter onTimeout: " + timeoutBroadcastName);    // or System.out.println("")

        // an empty no-op in android source code (despite AI)
        // super.onTimeout(startId, fgsType);

        // signal Py app to reset GUI, show popup, and stop BR
        timeoutBroadcastName = "com.quixotely.usbsync.SCRIPT_EXIT";
        Intent intent = new Intent(timeoutBroadcastName);
        intent.putExtra("WhyStopped", "Timeout");             // key, value
        sendBroadcast(intent);                                // Context method

        // stop fg service gracefully(ish)
        stopSelf(startId);

        Log.d(LOGTAG, "Exit onTimeout");


        /*
        ---------------------------------------------------------------------------
        Per android source code, the foreground service can be stopped by any of:
          
          - android.app.Service#stopSelf() - which seems most recommended
          - android.content.Context#stopService(android.content.Intent) or their overloads;
            this is what the generated Service class's stop() method does
          - android.app.Service#stopForeground(int) can be used as well, which demotes the
            service to a "background" service that will soon be stopped by the system

        But the following alternatives all left the app in an usuable state:

        // 1) most recommended: do this within a "few" seconds to avoid crash
        // stopSelf(startId);
        
        // 2) moves svc from FG  to BG, closes its notificaton
        // stopForeground(STOP_FOREGROUND_REMOVE);

        // 3) which calls Context#stopService(android.content.Intent)
        // ((ServiceRunscript) this).stop(getApplicationContext());

        // 4) kill service process, not app; runs Process.killProcess(Process.myPid())
        // onDestroy();

        // 5) nuclear option: should run in and kill service process
        // System.exit(0);

        // 6) manifest-insert add: but seems to be default for swipes today
        // android:stopWithTask="true"

        // 7) force removal of notification - and any impacts on service
        // NotificationManager notificationManager = getSystemService(NotificationManager.class);
        // notificationManager.cancel(getServiceId());

        // 8) overkill combos: did this in onTimeout(), onTaskRemoved(), and run() 
        // stopForeground(STOP_FOREGROUND_REMOVE);    # moot
        // stopSelf();                                # also tried #6+#7+#8+#9 combo; sigh

        // 9) overkill start call in this file
        // startForeground(getServiceId(), notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC);
        ---------------------------------------------------------------------------
        */
    }
}