diff --git a/android/.gitignore b/android/.gitignore index 297d122f..c1f63742 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -12,5 +12,4 @@ local.properties build.sh android.iml build - - +*.iml diff --git a/android/AndroidManifest.xml b/android/AndroidManifest.xml index 2ae14711..757b35bb 100755 --- a/android/AndroidManifest.xml +++ b/android/AndroidManifest.xml @@ -9,6 +9,7 @@ + remaining Prompt SD card write permission denied, you need to allow this to continue + Battery optimizations enabled + Your device is doing some heavy battery optimizations on I2PD that might lead to daemon closing with no other reason.\nIt is recommended to disable those battery optimizations. + Your device is doing some heavy battery optimizations on I2PD that might lead to daemon closing with no other reason.\n\nYou will now be asked to disable those. + Next + Your device does not support opting out of battery optimizations diff --git a/android/src/org/purplei2p/i2pd/ForegroundService.java b/android/src/org/purplei2p/i2pd/ForegroundService.java index 5c10e138..c1b1cc26 100644 --- a/android/src/org/purplei2p/i2pd/ForegroundService.java +++ b/android/src/org/purplei2p/i2pd/ForegroundService.java @@ -1,6 +1,5 @@ package org.purplei2p.i2pd; -import android.annotation.TargetApi; import android.app.Notification; import android.app.NotificationChannel; import android.app.NotificationManager; @@ -11,10 +10,9 @@ import android.content.Intent; import android.os.Binder; import android.os.Build; import android.os.IBinder; -import android.support.annotation.RequiresApi; -import android.support.v4.app.NotificationCompat; +import androidx.annotation.RequiresApi; +import androidx.core.app.NotificationCompat; import android.util.Log; -import android.widget.Toast; public class ForegroundService extends Service { private static final String TAG="FgService"; @@ -112,14 +110,15 @@ public class ForegroundService extends Service { // If earlier version channel ID is not used // https://developer.android.com/reference/android/support/v4/app/NotificationCompat.Builder.html#NotificationCompat.Builder(android.content.Context) - String channelId = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) ? createNotificationChannel() : ""; + String channelId = Build.VERSION.SDK_INT >= 26 ? createNotificationChannel() : ""; // Set the info for the views that show in the notification panel. - Notification notification = new NotificationCompat.Builder(this, channelId) + NotificationCompat.Builder builder = new NotificationCompat.Builder(this, channelId) .setOngoing(true) - .setSmallIcon(R.drawable.itoopie_notification_icon) // the status icon - .setPriority(Notification.PRIORITY_DEFAULT) - .setCategory(Notification.CATEGORY_SERVICE) + .setSmallIcon(R.drawable.itoopie_notification_icon); // the status icon + if(Build.VERSION.SDK_INT >= 16) builder = builder.setPriority(Notification.PRIORITY_DEFAULT); + if(Build.VERSION.SDK_INT >= 21) builder = builder.setCategory(Notification.CATEGORY_SERVICE); + Notification notification = builder .setTicker(text) // the status text .setWhen(System.currentTimeMillis()) // the time stamp .setContentTitle(getText(R.string.app_name)) // the label of the entry @@ -141,9 +140,10 @@ public class ForegroundService extends Service { //chan.setLightColor(Color.PURPLE); chan.setLockscreenVisibility(Notification.VISIBILITY_PRIVATE); NotificationManager service = (NotificationManager)getSystemService(Context.NOTIFICATION_SERVICE); - service.createNotificationChannel(chan); + if(service!=null)service.createNotificationChannel(chan); + else Log.e(TAG, "error: NOTIFICATION_SERVICE is null"); return channelId; } - private static final DaemonSingleton daemon = DaemonSingleton.getInstance(); + private static final DaemonSingleton daemon = DaemonSingleton.getInstance(); } diff --git a/android/src/org/purplei2p/i2pd/I2PDActivity.java b/android/src/org/purplei2p/i2pd/I2PDActivity.java index b5f85c5b..d97df833 100755 --- a/android/src/org/purplei2p/i2pd/I2PDActivity.java +++ b/android/src/org/purplei2p/i2pd/I2PDActivity.java @@ -14,24 +14,34 @@ import java.util.Timer; import java.util.TimerTask; import android.Manifest; +import android.annotation.SuppressLint; import android.app.Activity; +import android.app.AlertDialog; +import android.content.ActivityNotFoundException; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.ServiceConnection; +import android.content.SharedPreferences; import android.content.res.AssetManager; import android.content.pm.PackageManager; +import android.net.Uri; import android.os.Bundle; import android.os.Build; import android.os.Environment; import android.os.IBinder; +import android.os.PowerManager; +import android.preference.PreferenceManager; +import android.provider.Settings; import android.util.Log; import android.view.Menu; import android.view.MenuItem; import android.widget.TextView; import android.widget.Toast; -import android.support.v4.app.ActivityCompat; -import android.support.v4.content.ContextCompat; + +import androidx.annotation.NonNull; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; // For future package update checking import org.purplei2p.i2pd.BuildConfig; @@ -40,6 +50,7 @@ public class I2PDActivity extends Activity { private static final String TAG = "i2pdActvt"; private static final int MY_PERMISSION_REQUEST_WRITE_EXTERNAL_STORAGE = 1; public static final int GRACEFUL_DELAY_MILLIS = 10 * 60 * 1000; + public static final String PACKAGE_URI_SCHEME = "package:"; private TextView textView; private boolean assetsCopied; @@ -53,28 +64,22 @@ public class I2PDActivity extends Activity { public void daemonStateUpdate() { processAssets(); - runOnUiThread(new Runnable(){ - - @Override - public void run() { - try { - if(textView==null) return; - Throwable tr = daemon.getLastThrowable(); - if(tr!=null) { - textView.setText(throwableToString(tr)); - return; - } - DaemonSingleton.State state = daemon.getState(); - textView.setText( - String.valueOf(getText(state.getStatusStringResourceId()))+ - (DaemonSingleton.State.startFailed.equals(state) ? ": "+daemon.getDaemonStartResult() : "")+ - (DaemonSingleton.State.gracefulShutdownInProgress.equals(state) ? ": "+formatGraceTimeRemaining()+" "+getText(R.string.remaining) : "") - ); - } catch (Throwable tr) { - Log.e(TAG,"error ignored",tr); - } - } - }); + runOnUiThread(() -> { + try { + if(textView==null) return; + Throwable tr = daemon.getLastThrowable(); + if(tr!=null) { + textView.setText(throwableToString(tr)); + return; + } + DaemonSingleton.State state = daemon.getState(); + String startResultStr = DaemonSingleton.State.startFailed.equals(state) ? String.format(": %s", daemon.getDaemonStartResult()) : ""; + String graceStr = DaemonSingleton.State.gracefulShutdownInProgress.equals(state) ? String.format(": %s %s", formatGraceTimeRemaining(), getText(R.string.remaining)) : ""; + textView.setText(String.format("%s%s%s", getText(state.getStatusStringResourceId()), startResultStr, graceStr)); + } catch (Throwable tr) { + Log.e(TAG,"error ignored",tr); + } + }); } }; private static volatile long graceStartedMillis; @@ -92,6 +97,7 @@ public class I2PDActivity extends Activity { @Override public void onCreate(Bundle savedInstanceState) { + Log.i(TAG, "onCreate"); super.onCreate(savedInstanceState); textView = new TextView(this); @@ -121,6 +127,8 @@ public class I2PDActivity extends Activity { } rescheduleGraceStop(gracefulQuitTimer, gracefulStopAtMillis); } + + openBatteryOptimizationDialogIfNeeded(); } @Override @@ -137,21 +145,17 @@ public class I2PDActivity extends Activity { } @Override - public void onRequestPermissionsResult(int requestCode, String permissions[], int[] grantResults) + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { - switch (requestCode) - { - case MY_PERMISSION_REQUEST_WRITE_EXTERNAL_STORAGE: - { - if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) - Log.e(TAG, "Memory permission granted"); - else - Log.e(TAG, "Memory permission declined"); - // TODO: terminate - return; - } - default: ; - } + if (requestCode == MY_PERMISSION_REQUEST_WRITE_EXTERNAL_STORAGE) { + if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) + Log.e(TAG, "WR_EXT_STORAGE perm granted"); + else { + Log.e(TAG, "WR_EXT_STORAGE perm declined, stopping i2pd"); + i2pdStop(); + //TODO must work w/o this perm, ask orignal + } + } } private static void cancelGracefulStop() { @@ -229,7 +233,7 @@ public class I2PDActivity extends Activity { } @Override - public boolean onOptionsItemSelected(MenuItem item) { + public boolean onOptionsItemSelected(@NonNull MenuItem item) { // Handle action bar item clicks here. The action bar will // automatically handle clicks on the Home/Up button, so long // as you specify a parent activity in AndroidManifest.xml. @@ -258,19 +262,15 @@ public class I2PDActivity extends Activity { private void i2pdStop() { cancelGracefulStop(); - new Thread(new Runnable(){ - - @Override - public void run() { - Log.d(TAG, "stopping"); - try{ - daemon.stopDaemon(); - }catch (Throwable tr) { - Log.e(TAG, "", tr); - } - } - - },"stop").start(); + new Thread(() -> { + Log.d(TAG, "stopping"); + try { + daemon.stopDaemon(); + } catch (Throwable tr) { + Log.e(TAG, "", tr); + } + quit(); //TODO make menu items for starting i2pd. On my Android, I need to reboot the OS to restart i2pd. + },"stop").start(); } private static volatile Timer gracefulQuitTimer; @@ -288,55 +288,44 @@ public class I2PDActivity extends Activity { } Toast.makeText(this, R.string.graceful_stop_is_in_progress, Toast.LENGTH_SHORT).show(); - new Thread(new Runnable(){ - - @Override - public void run() { - try { - Log.d(TAG, "grac stopping"); - if(daemon.isStartedOkay()) { - daemon.stopAcceptingTunnels(); - long gracefulStopAtMillis; - synchronized (graceStartedMillis_LOCK) { - graceStartedMillis = System.currentTimeMillis(); - gracefulStopAtMillis = graceStartedMillis + GRACEFUL_DELAY_MILLIS; - } - rescheduleGraceStop(null,gracefulStopAtMillis); - } else { - i2pdStop(); - } - } catch(Throwable tr) { - Log.e(TAG,"",tr); - } - } - - },"gracInit").start(); + new Thread(() -> { + try { + Log.d(TAG, "grac stopping"); + if(daemon.isStartedOkay()) { + daemon.stopAcceptingTunnels(); + long gracefulStopAtMillis; + synchronized (graceStartedMillis_LOCK) { + graceStartedMillis = System.currentTimeMillis(); + gracefulStopAtMillis = graceStartedMillis + GRACEFUL_DELAY_MILLIS; + } + rescheduleGraceStop(null,gracefulStopAtMillis); + } else { + i2pdStop(); + } + } catch(Throwable tr) { + Log.e(TAG,"",tr); + } + },"gracInit").start(); } private void i2pdCancelGracefulStop() { cancelGracefulStop(); Toast.makeText(this, R.string.startedOkay, Toast.LENGTH_SHORT).show(); - new Thread(new Runnable() - { - @Override - public void run() - { - try - { - Log.d(TAG, "grac stopping cancel"); - if(daemon.isStartedOkay()) - daemon.startAcceptingTunnels(); - else - i2pdStop(); - } - catch(Throwable tr) - { - Log.e(TAG,"",tr); - } - } - - },"gracCancel").start(); + new Thread(() -> { + try + { + Log.d(TAG, "grac stopping cancel"); + if(daemon.isStartedOkay()) + daemon.startAcceptingTunnels(); + else + i2pdStop(); + } + catch(Throwable tr) + { + Log.e(TAG,"",tr); + } + },"gracCancel").start(); } private void rescheduleGraceStop(Timer gracefulQuitTimerOld, long gracefulStopAtMillis) { @@ -393,7 +382,7 @@ public class I2PDActivity extends Activity { // Make the directory. File dir = new File(i2pdpath, path); - dir.mkdirs(); + Log.d(TAG, "dir.mkdirs() returned "+dir.mkdirs()); // Recurse on the contents. for (String entry : contents) { @@ -431,45 +420,69 @@ public class I2PDActivity extends Activity { private void deleteRecursive(File fileOrDirectory) { if (fileOrDirectory.isDirectory()) { - for (File child : fileOrDirectory.listFiles()) { - deleteRecursive(child); + File[] files = fileOrDirectory.listFiles(); + if(files!=null) { + for (File child : files) { + deleteRecursive(child); + } } } - fileOrDirectory.delete(); + boolean deleteResult = fileOrDirectory.delete(); + if(!deleteResult)Log.e(TAG, "fileOrDirectory.delete() returned "+deleteResult+", absolute path='"+fileOrDirectory.getAbsolutePath()+"'"); } private void processAssets() { if (!assetsCopied) try { assetsCopied = true; // prevent from running on every state update - File holderfile = new File(i2pdpath, "assets.ready"); + File holderFile = new File(i2pdpath, "assets.ready"); String versionName = BuildConfig.VERSION_NAME; // here will be app version, like 2.XX.XX StringBuilder text = new StringBuilder(); - if (holderfile.exists()) try { // if holder file exists, read assets version string - BufferedReader br = new BufferedReader(new FileReader(holderfile)); - String line; + if (holderFile.exists()) { + try { // if holder file exists, read assets version string + FileReader fileReader = new FileReader(holderFile); - while ((line = br.readLine()) != null) { - text.append(line); - } - br.close(); - } - catch (IOException e) { - Log.e(TAG, "", e); - } + try { + BufferedReader br = new BufferedReader(fileReader); + + try { + String line; + + while ((line = br.readLine()) != null) { + text.append(line); + } + }finally { + try{ + br.close(); + } catch (IOException e) { + Log.e(TAG, "", e); + } + } + } finally { + try{ + fileReader.close(); + } catch (IOException e) { + Log.e(TAG, "", e); + } + } + } catch (IOException e) { + Log.e(TAG, "", e); + } + } // if version differs from current app version or null, try to delete certificates folder if (!text.toString().contains(versionName)) try { - holderfile.delete(); - File certpath = new File(i2pdpath, "certificates"); - deleteRecursive(certpath); + boolean deleteResult = holderFile.delete(); + if(!deleteResult)Log.e(TAG, "holderFile.delete() returned "+deleteResult+", absolute path='"+holderFile.getAbsolutePath()+"'"); + File certPath = new File(i2pdpath, "certificates"); + deleteRecursive(certPath); } catch (Throwable tr) { Log.e(TAG, "", tr); } - // copy assets. If processed file exists, it won't be overwrited + // copy assets. If processed file exists, it won't be overwritten copyAsset("addressbook"); copyAsset("certificates"); copyAsset("tunnels.d"); @@ -478,14 +491,95 @@ public class I2PDActivity extends Activity { copyAsset("tunnels.conf"); // update holder file about successful copying - FileWriter writer = new FileWriter(holderfile); - writer.append(versionName); - writer.flush(); - writer.close(); + FileWriter writer = new FileWriter(holderFile); + try { + writer.append(versionName); + } finally { + try{ + writer.close(); + }catch (IOException e){ + Log.e(TAG,"on writer close", e); + } + } } catch (Throwable tr) { - Log.e(TAG,"copy assets",tr); + Log.e(TAG,"on assets copying", tr); } } + + @SuppressLint("BatteryLife") + private void openBatteryOptimizationDialogIfNeeded() { + boolean questionEnabled = getPreferences().getBoolean(getBatteryOptimizationPreferenceKey(), true); + Log.i(TAG,"BATT_OPTIM_questionEnabled=="+questionEnabled); + if (!isKnownIgnoringBatteryOptimizations() + && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M + && questionEnabled) { + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle(R.string.battery_optimizations_enabled); + builder.setMessage(R.string.battery_optimizations_enabled_dialog); + builder.setPositiveButton(R.string.next, (dialog, which) -> { + try { + startActivity(new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, Uri.parse(PACKAGE_URI_SCHEME + getPackageName()))); + } catch (ActivityNotFoundException e) { + Log.e(TAG,"BATT_OPTIM_ActvtNotFound", e); + Toast.makeText(this, R.string.device_does_not_support_disabling_battery_optimizations, Toast.LENGTH_SHORT).show(); + } + }); + builder.setOnDismissListener(dialog -> setNeverAskForBatteryOptimizationsAgain()); + final AlertDialog dialog = builder.create(); + dialog.setCanceledOnTouchOutside(false); + dialog.show(); + } + } + + private void setNeverAskForBatteryOptimizationsAgain() { + getPreferences().edit().putBoolean(getBatteryOptimizationPreferenceKey(), false).apply(); + } + + protected boolean isKnownIgnoringBatteryOptimizations() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + final PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE); + if (pm == null) { + Log.i(TAG, "BATT_OPTIM: POWER_SERVICE==null"); + return false; + } + boolean ignoring = pm.isIgnoringBatteryOptimizations(getPackageName()); + Log.i(TAG, "BATT_OPTIM: ignoring==" + ignoring); + return ignoring; + } else { + Log.i(TAG, "BATT_OPTIM: old sdk version=="+Build.VERSION.SDK_INT); + return false; + } + } + + protected SharedPreferences getPreferences() { + return PreferenceManager.getDefaultSharedPreferences(getApplicationContext()); + } + + private String getBatteryOptimizationPreferenceKey() { + @SuppressLint("HardwareIds") String device = Settings.Secure.getString(getContentResolver(), Settings.Secure.ANDROID_ID); + return "show_battery_optimization" + (device == null ? "" : device); + } + + private void quit() { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + finishAndRemoveTask(); + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + finishAffinity(); + } else { + //moveTaskToBack(true); + finish(); + } + }catch (Throwable tr) { + Log.e(TAG, "", tr); + } + try{ + daemon.stopDaemon(); + }catch (Throwable tr) { + Log.e(TAG, "", tr); + } + System.exit(0); + } }