commit 4bb071e488a25a2476206ebc6b390b75d3a58e6c Author: Alexander Dörflinger Date: Tue Mar 11 17:27:53 2025 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..b3405b3 --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +My Application \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b589d56 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetDropDown.xml b/.idea/deploymentTargetDropDown.xml new file mode 100644 index 0000000..0c0c338 --- /dev/null +++ b/.idea/deploymentTargetDropDown.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..0897082 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..8978d23 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..3d8607f --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,39 @@ +plugins { + id 'com.android.application' +} + +android { + namespace 'com.aldo.apps.familyhelpers' + compileSdk 34 + + defaultConfig { + applicationId "com.aldo.apps.familyhelpers" + minSdk 32 + targetSdk 34 + versionCode 1 + versionName "1.0" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } +} + +dependencies { + + implementation 'androidx.appcompat:appcompat:1.7.0' + implementation 'com.google.android.material:material:1.12.0' + implementation 'androidx.constraintlayout:constraintlayout:2.2.0' + testImplementation 'junit:junit:4.13.2' + androidTestImplementation 'androidx.test.ext:junit:1.2.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/com/aldo/apps/familyhelpers/ExampleInstrumentedTest.java b/app/src/androidTest/java/com/aldo/apps/familyhelpers/ExampleInstrumentedTest.java new file mode 100644 index 0000000..87c80a9 --- /dev/null +++ b/app/src/androidTest/java/com/aldo/apps/familyhelpers/ExampleInstrumentedTest.java @@ -0,0 +1,26 @@ +package com.aldo.apps.familyhelpers; + +import android.content.Context; + +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.*; + +/** + * Instrumented test, which will execute on an Android device. + * + * @see Testing documentation + */ +@RunWith(AndroidJUnit4.class) +public class ExampleInstrumentedTest { + @Test + public void useAppContext() { + // Context of the app under test. + Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + assertEquals("com.aldo.apps.familyhelpers", appContext.getPackageName()); + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..66773c4 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/aldo/apps/familyhelpers/DoerflingerHelpersDeviceAdminReceiver.java b/app/src/main/java/com/aldo/apps/familyhelpers/DoerflingerHelpersDeviceAdminReceiver.java new file mode 100644 index 0000000..9c2db80 --- /dev/null +++ b/app/src/main/java/com/aldo/apps/familyhelpers/DoerflingerHelpersDeviceAdminReceiver.java @@ -0,0 +1,17 @@ +package com.aldo.apps.familyhelpers; + +import android.app.admin.DeviceAdminReceiver; +import android.content.Context; +import android.content.Intent; + +public class DoerflingerHelpersDeviceAdminReceiver extends DeviceAdminReceiver { + @Override + public void onEnabled(final Context context,final Intent intent) { + // Called when the app is enabled as a device administrator. + } + + @Override + public void onDisabled(final Context context, final Intent intent) { + // Called when the app is disabled as a device administrator. + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aldo/apps/familyhelpers/HelperGridActivity.java b/app/src/main/java/com/aldo/apps/familyhelpers/HelperGridActivity.java new file mode 100644 index 0000000..32a9c75 --- /dev/null +++ b/app/src/main/java/com/aldo/apps/familyhelpers/HelperGridActivity.java @@ -0,0 +1,112 @@ +package com.aldo.apps.familyhelpers; + +import static android.Manifest.permission.POST_NOTIFICATIONS; + +import android.content.pm.PackageManager; +import android.os.Bundle; +import android.util.Log; +import android.widget.Toast; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.content.ContextCompat; + +import com.aldo.apps.familyhelpers.ui.HelperGroupTile; +import com.aldo.apps.familyhelpers.ui.SleepTimerPopup; +import com.aldo.apps.familyhelpers.utils.DevicePolicyManagerHelper; + +/** + * The Activity showing the Grid of helpers to select from. + */ +public class HelperGridActivity extends AppCompatActivity { + + /** + * Tag for debugging purpose. + */ + private static final String TAG = "HelperGridActivity"; + + /** + * {@link HelperGroupTile} holding the option for a sleep timer. + */ + private HelperGroupTile mSleepTimerTile; + + /** + * Instance of the {@link DevicePolicyManagerHelper} to roll out device specific actions. + */ + private DevicePolicyManagerHelper mDevicePolicyHelper; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_main); + mDevicePolicyHelper = DevicePolicyManagerHelper.getInstance(this); + initSleepTimer(); + } /** + * The {@link ActivityResultLauncher} to ask for the NotificationPermission. + */ + private final ActivityResultLauncher mRequestPermissionLauncher = + registerForActivityResult(new ActivityResultContracts.RequestPermission(), isGranted -> { + if (isGranted) { + // Permission granted, you can post notifications + mSleepTimerTile.launchHelper(); + } else { + // Permission denied, handle accordingly + Toast.makeText(HelperGridActivity.this, R.string.sleep_timer_show_notifications_rationale, + Toast.LENGTH_LONG).show(); + requestNotificationPermission(); + } + }); + + /** + * Helper method to initialize the sleepTimer tile. + */ + private void initSleepTimer() { + mSleepTimerTile = new HelperGroupTile(findViewById(R.id.tile_sleep_timer)) { + @Override + public void launchHelper() { + // First check if the notification permission is granted + if (!requestNotificationPermission()) { + Log.d(TAG, "launchHelper: Notifications not allowed, return..."); + return; + } + // Second check for the app to be registered as DeviceAdmin + if (!mDevicePolicyHelper.isDeviceAdmin()) { + Toast.makeText(HelperGridActivity.this, R.string.warning_no_device_admin, + Toast.LENGTH_LONG).show(); + mDevicePolicyHelper.requestDeviceAdminPrivileges(HelperGridActivity.this, + R.string.sleep_timer_rationale_device_admin); + return; + } + // If both previous checks pass, launch the actual sleep timer dialog. + final SleepTimerPopup sleepTimerPopup = new SleepTimerPopup(HelperGridActivity.this); + sleepTimerPopup.showTimePicker(); + Log.d(TAG, "launchHelper: Clicked SleepTimer"); + } + }; + mSleepTimerTile.setLogo(R.drawable.icn_sleep_timer); + mSleepTimerTile.setTitle(R.string.title_sleep_timer); + } + + /** + * Helper method to request the NotificationPermission as it is required for the service to run. + * + * @return true if the permission is granted, false otherwise. + */ + private boolean requestNotificationPermission() { + if (ContextCompat.checkSelfPermission(this, POST_NOTIFICATIONS) == + PackageManager.PERMISSION_GRANTED) { + // Permission already granted + return true; + } else { + if (shouldShowRequestPermissionRationale(POST_NOTIFICATIONS)) { + Toast.makeText(HelperGridActivity.this, R.string.sleep_timer_show_notifications_rationale, + Toast.LENGTH_LONG).show(); + } + mRequestPermissionLauncher.launch(POST_NOTIFICATIONS); + return false; + } + } + + +} \ No newline at end of file diff --git a/app/src/main/java/com/aldo/apps/familyhelpers/ui/HelperGroupTile.java b/app/src/main/java/com/aldo/apps/familyhelpers/ui/HelperGroupTile.java new file mode 100644 index 0000000..e8676a0 --- /dev/null +++ b/app/src/main/java/com/aldo/apps/familyhelpers/ui/HelperGroupTile.java @@ -0,0 +1,115 @@ +package com.aldo.apps.familyhelpers.ui; + +import android.content.Context; +import android.util.Log; +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.DrawableRes; +import androidx.annotation.StringRes; + +import com.aldo.apps.familyhelpers.R; + +import java.lang.ref.WeakReference; + +/** + * Helper class to avoid boiler plate code for inflating more and more helper tiles. + * Abstracts the inflation of the logo and the title and offers a way to handle the click. + */ +public abstract class HelperGroupTile implements View.OnClickListener { + + /** + * Tag for debugging purpose. + */ + private static final String TAG = "HelperGroupTile"; + + /** + * The Logo of the Helper group. + */ + private final ImageView mHelperLogo; + + /** + * The title of the Helper group. + */ + private final TextView mHelperTitle; + + /** + * The {@link WeakReference} of the {@link Context} from where this was instantiated. + */ + private WeakReference mContextRef; + + /** + * The name of the helper, mainly used for logging purpose. + */ + private String mHelperName; + + /** + * C'tor. + * + * @param rootLayout The previously inflated view of the root layout. + */ + public HelperGroupTile(final View rootLayout) { + mContextRef = new WeakReference<>(rootLayout.getContext()); + mHelperLogo = rootLayout.findViewById(R.id.iv_helper_group_icon); + mHelperTitle = rootLayout.findViewById(R.id.tv_helper_group_title); + rootLayout.setOnClickListener(this); + } + + /** + * To be invoked when the tile is pressed. + */ + public abstract void launchHelper(); + + /** + * Helper method to set the Name of the HelperGroup. + * + * @param titleId The {@link StringRes} id of the title to be shown. + */ + public void setTitle(@StringRes final int titleId) { + final Context context = mContextRef.get(); + if (context == null) { + Log.w(TAG, "setTitle: Null context provided, cannot continue"); + return; + } + mHelperName = context.getString(titleId); + if (mHelperTitle != null) { + mHelperTitle.setText(titleId); + } else { + Log.d(TAG, "setLogo: Cannot set Logo for [" + mHelperName + "]"); + } + } + + /** + * Helper method to set the Name of the HelperGroup. + * + * @param groupTitle The String to be used as a title for the group. + */ + public void setTitleString(final String groupTitle) { + mHelperName = groupTitle; + if (mHelperTitle != null) { + mHelperTitle.setText(groupTitle); + } else { + Log.d(TAG, "setLogo: Cannot set Logo for [" + mHelperName + "]"); + } + } + + /** + * Helper method to set the Logo of the HelperGroup. + * + * @param logoId The {@link DrawableRes} id of the logo to be shown. + */ + public void setLogo(@DrawableRes final int logoId) { + if (mHelperLogo != null) { + mHelperLogo.setImageResource(logoId); + } else { + Log.d(TAG, "setLogo: Cannot set Logo for [" + mHelperName + "]"); + } + } + + @Override + public void onClick(final View view) { + Log.d(TAG, "onClick: Clicked on [" + mHelperName + "]"); + launchHelper(); + } +} diff --git a/app/src/main/java/com/aldo/apps/familyhelpers/ui/SleepTimerPopup.java b/app/src/main/java/com/aldo/apps/familyhelpers/ui/SleepTimerPopup.java new file mode 100644 index 0000000..97f6891 --- /dev/null +++ b/app/src/main/java/com/aldo/apps/familyhelpers/ui/SleepTimerPopup.java @@ -0,0 +1,81 @@ +package com.aldo.apps.familyhelpers.ui; + +import static com.aldo.apps.familyhelpers.utils.GlobalConstants.SLEEP_TIMER_DURATION_MILLIS_EXTRA; + +import android.app.TimePickerDialog; +import android.content.Context; +import android.content.Intent; +import android.util.Log; +import android.widget.TimePicker; + +import com.aldo.apps.familyhelpers.workers.SleepTimerHelper; + +import java.lang.ref.WeakReference; +import java.util.Calendar; + +/** + * Helper class for the Popup to select a time where you want to have the screen locked. + */ +public class SleepTimerPopup { + + /** + * Tag for debugging purpose. + */ + private static final String TAG = "SleepTimerPopup"; + + /** + * {@link WeakReference} to the {@link Context} from where this was called. + */ + private final WeakReference mContextRef; + + /** + * C'tor. + * + * @param context The {@link Context} from where this was called. + */ + public SleepTimerPopup(final Context context) { + mContextRef = new WeakReference<>(context); + } + + /** + * Initializes and shows the time picker, pre-selected to the current time of the day. + */ + public void showTimePicker() { + final Context context = mContextRef.get(); + final Calendar calendar = Calendar.getInstance(); + final int hour = calendar.get(Calendar.HOUR_OF_DAY); + final int minute = calendar.get(Calendar.MINUTE); + + final TimePickerDialog timePickerDialog + = new TimePickerDialog(context, this::handleSelectedTime, hour, minute, true); + timePickerDialog.show(); + } + + /** + * Helper method to handle the selected time from the widget. Will calculate the milliseconds until + * the timer expires from now and start the actual {@link SleepTimerHelper}. + * + * @param view Added for convenience purpose, not really needed. + * @param hour The selected hour. + * @param minute The selected minute. + */ + private void handleSelectedTime(final TimePicker view, final int hour, final int minute) { + Log.d(TAG, "handleSelectedTime() called with: hour = [" + hour + "], minute = [" + minute + "]"); + final Calendar selectedTimeCalendar = Calendar.getInstance(); + final Calendar nowCalendar = Calendar.getInstance(); + selectedTimeCalendar.set(Calendar.HOUR_OF_DAY, hour); + selectedTimeCalendar.set(Calendar.MINUTE, minute); + // If the time is before now, add a day to make it expire tomorrow. + if (selectedTimeCalendar.before(nowCalendar)) { + Log.d(TAG, "handleSelectedTime: Selected a time in the past, adding a day"); + selectedTimeCalendar.add(Calendar.DAY_OF_YEAR, 1); + } + final long differenceMillis = selectedTimeCalendar.getTimeInMillis() - nowCalendar.getTimeInMillis(); + Log.d(TAG, "handleSelectedTime: Selected [" + differenceMillis + "] Milliseconds "); + final Context context = mContextRef.get(); + + final Intent sleepTimerServiceIntent = new Intent(context, SleepTimerHelper.class); + sleepTimerServiceIntent.putExtra(SLEEP_TIMER_DURATION_MILLIS_EXTRA, differenceMillis); + context.startForegroundService(sleepTimerServiceIntent); + } +} diff --git a/app/src/main/java/com/aldo/apps/familyhelpers/utils/DevicePolicyManagerHelper.java b/app/src/main/java/com/aldo/apps/familyhelpers/utils/DevicePolicyManagerHelper.java new file mode 100644 index 0000000..344d020 --- /dev/null +++ b/app/src/main/java/com/aldo/apps/familyhelpers/utils/DevicePolicyManagerHelper.java @@ -0,0 +1,105 @@ +package com.aldo.apps.familyhelpers.utils; + +import static android.content.Context.DEVICE_POLICY_SERVICE; + +import android.app.admin.DevicePolicyManager; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +import androidx.annotation.StringRes; + +import com.aldo.apps.familyhelpers.DoerflingerHelpersDeviceAdminReceiver; + +import java.lang.ref.WeakReference; + +/** + * Helper class to wrap all the {@link DevicePolicyManager} related tasks into one singleton instead + * of instantiating it scattered all over the code. + */ +public final class DevicePolicyManagerHelper { + + /** + * Tag for debugging purpose. + */ + private static final String TAG = "DevicePolicyManagerHelper"; + + /** + * The singleton instance of this class. + */ + private static DevicePolicyManagerHelper sInstance; + + /** + * {@link WeakReference} to the {@link DevicePolicyManager} to apply actual system settings. + */ + private final WeakReference mDevicePolicyManagerRef; + + /** + * The {@link ComponentName} of the {@link {@link DoerflingerHelpersDeviceAdminReceiver}}. + */ + private final ComponentName mDeviceAdminReceiver; + + /** + * Private C'Tor to prevent instantiation from outside. + * + * @param context The {@link Context} from where this was called. + */ + private DevicePolicyManagerHelper(final Context context) { + mDevicePolicyManagerRef = new WeakReference<>((DevicePolicyManager) context.getSystemService(DEVICE_POLICY_SERVICE)); + mDeviceAdminReceiver = new ComponentName(context, DoerflingerHelpersDeviceAdminReceiver.class); + } + + /** + * Returns the singleton instance of the {@link DevicePolicyManagerHelper}. + * + * @param context The {@link Context} from where this was called. + * + * @return the singleton instance of the {@link DevicePolicyManagerHelper}. + */ + public static DevicePolicyManagerHelper getInstance(final Context context) { + if (sInstance == null) { + Log.d(TAG, "getInstance: Creating new helper instance"); + sInstance = new DevicePolicyManagerHelper(context); + } + return sInstance; + } + + /** + * If not yet granted before, opens the settings page to grant deviceAdmin rights to the package. + * + * @param context The {@link Context} from where this was called. + * @param explanationId The stringId of the explanation on why this is needed. + */ + public void requestDeviceAdminPrivileges(final Context context, @StringRes final int explanationId) { + final String explanation = context.getString(explanationId); + final Intent intent = new Intent(DevicePolicyManager.ACTION_ADD_DEVICE_ADMIN); + intent.putExtra(DevicePolicyManager.EXTRA_DEVICE_ADMIN, mDeviceAdminReceiver); + intent.putExtra(DevicePolicyManager.EXTRA_ADD_EXPLANATION, explanation); + context.startActivity(intent); + } + + /** + * Helper method to check whether the package was already registered as DeviceAdmin or not. + * + * @return true if package is approved deviceAdmin, false otherwise. + */ + public boolean isDeviceAdmin() { + final DevicePolicyManager devicePolicyManager = mDevicePolicyManagerRef.get(); + return devicePolicyManager.isAdminActive(mDeviceAdminReceiver); + } + + /** + * Actually locks the screen when the sleepTimer expired. + * + * @return true, if the screen lock was performed, false otherwise. + */ + public boolean lockScreen() { + final DevicePolicyManager devicePolicyManager = mDevicePolicyManagerRef.get(); + if (isDeviceAdmin()) { + devicePolicyManager.lockNow(); + return true; + } + return false; + } +} diff --git a/app/src/main/java/com/aldo/apps/familyhelpers/utils/GlobalConstants.java b/app/src/main/java/com/aldo/apps/familyhelpers/utils/GlobalConstants.java new file mode 100644 index 0000000..cfdc756 --- /dev/null +++ b/app/src/main/java/com/aldo/apps/familyhelpers/utils/GlobalConstants.java @@ -0,0 +1,43 @@ +package com.aldo.apps.familyhelpers.utils; + +/** + * Utility class holding global constants to be used for the application. + */ +public final class GlobalConstants { + + /** + * Private C'tor to prevent instantiation. + */ + private GlobalConstants() {} + + /** + * ID of the NotificationChannel for the SleepTimer Notification. + */ + public static final String SLEEP_TIMER_CHANNEL_ID = "CountdownChannel"; + + /** + * Factor to calculate seconds from milliseconds and vice versa. + */ + public static final int ONE_SECOND_IN_MILLIS = 1000; + + /** + * Factor to calculate hours from minutes and vice versa. + */ + public static final int ONE_HOUR_IN_MINUTES = 60; + + /** + * The NotificationID of the SleepTimer notification. + */ + public static final int SLEEP_TIMER_NOTIFICATION_ID = 1; + + /** + * The key of the extra to be applied to the starting intent of the sleepTimer service, + * holding the initial duration in millis. + */ + public static final String SLEEP_TIMER_DURATION_MILLIS_EXTRA = "sleep_timer_duration"; + + /** + * Action to cancel an ongoing sleep timer. + */ + public static final String SLEEP_TIMER_CANCEL_ACTION = "SLEEP_TIMER_CANCEL"; +} diff --git a/app/src/main/java/com/aldo/apps/familyhelpers/workers/SleepTimerHelper.java b/app/src/main/java/com/aldo/apps/familyhelpers/workers/SleepTimerHelper.java new file mode 100644 index 0000000..16b3934 --- /dev/null +++ b/app/src/main/java/com/aldo/apps/familyhelpers/workers/SleepTimerHelper.java @@ -0,0 +1,189 @@ +package com.aldo.apps.familyhelpers.workers; + +import static com.aldo.apps.familyhelpers.utils.GlobalConstants.ONE_HOUR_IN_MINUTES; +import static com.aldo.apps.familyhelpers.utils.GlobalConstants.ONE_SECOND_IN_MILLIS; +import static com.aldo.apps.familyhelpers.utils.GlobalConstants.SLEEP_TIMER_CANCEL_ACTION; +import static com.aldo.apps.familyhelpers.utils.GlobalConstants.SLEEP_TIMER_CHANNEL_ID; +import static com.aldo.apps.familyhelpers.utils.GlobalConstants.SLEEP_TIMER_DURATION_MILLIS_EXTRA; +import static com.aldo.apps.familyhelpers.utils.GlobalConstants.SLEEP_TIMER_NOTIFICATION_ID; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Intent; +import android.os.Build; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; +import android.util.Log; + +import androidx.annotation.Nullable; +import androidx.core.app.NotificationCompat; + +import com.aldo.apps.familyhelpers.HelperGridActivity; +import com.aldo.apps.familyhelpers.R; +import com.aldo.apps.familyhelpers.utils.DevicePolicyManagerHelper; + +import java.util.concurrent.TimeUnit; + +/** + * Service implementation to post a notification and execute the locking of the screen after the + * #timeout ended. + */ +public class SleepTimerHelper extends Service { + + /** + * Tag for debugging purpose. + */ + private static final String TAG = "SleepTimerHelper"; + /** + * {@link Handler} on the MainThread in order to perform the actual locking. + */ + private final Handler mHandler = new Handler(Looper.getMainLooper()); + /** + * Member holding the remaining time of the countdown. + */ + private long mCountdownTimeMillis; + /** + * The {@link Runnable} to execute the task in. + */ + private Runnable mCountdownRunnable; + + /** + * The {@link DevicePolicyManagerHelper} to actually lock the screen. + */ + private DevicePolicyManagerHelper mDevicePolicyHelper; + + /** + * {@link NotificationManager} to show and update the foreground service notification. + */ + private NotificationManager mNotificationManager; + + @Override + public void onCreate() { + super.onCreate(); + Log.d(TAG, "onCreate() called"); + mDevicePolicyHelper = DevicePolicyManagerHelper.getInstance(this); + mNotificationManager = (NotificationManager) getSystemService(NOTIFICATION_SERVICE); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (intent == null) { + Log.e(TAG, "onStartCommand: Invalid intent intent received, do nothing"); + return START_REDELIVER_INTENT; + } + if (intent.getAction() != null && intent.getAction().equals(SLEEP_TIMER_CANCEL_ACTION)) { + stopSelf(); + return START_NOT_STICKY; + } + mCountdownTimeMillis = intent.getLongExtra(SLEEP_TIMER_DURATION_MILLIS_EXTRA, 0); + createNotificationChannel(); + startForeground(SLEEP_TIMER_NOTIFICATION_ID, buildNotification()); + mNotificationManager.notify(SLEEP_TIMER_NOTIFICATION_ID, buildNotification()); + startCountdown(); + return super.onStartCommand(intent, flags, startId); + } + + @Nullable + @Override + public IBinder onBind(Intent intent) { + return null; + } + + /** + * Helper method to prepare the actual runner to countdown, will finally lock the screen if the + * countdown expired. + */ + private void startCountdown() { + mCountdownRunnable = new Runnable() { + @Override + public void run() { + if (mCountdownTimeMillis > 0) { + updateNotification(); + mCountdownTimeMillis -= ONE_SECOND_IN_MILLIS; + mHandler.postDelayed(this, ONE_SECOND_IN_MILLIS); + } else { + mDevicePolicyHelper.lockScreen(); + Log.d(TAG, "run: Countdown expired, lock screen"); + stopSelf(); + } + } + }; + mHandler.post(mCountdownRunnable); + } + + /** + * Helper method to update the notification with the new time. + */ + private void updateNotification() { + final Notification notification = buildNotification(); + mNotificationManager.notify(SLEEP_TIMER_NOTIFICATION_ID, notification); + } + + /** + * Helper method to build the Notification to be shown showing the current progress of the countdown.# + * + * @return The built notification. + */ + private Notification buildNotification() { + final String countdownText = formatTime(mCountdownTimeMillis); + final Intent notificationIntent = new Intent(this, HelperGridActivity.class); + final PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, + notificationIntent, PendingIntent.FLAG_IMMUTABLE); + + final Intent cancelIntent = new Intent(this, SleepTimerHelper.class); + cancelIntent.setAction(SLEEP_TIMER_CANCEL_ACTION); + final PendingIntent cancelPendingIntent = PendingIntent.getService(this, 0, + cancelIntent, PendingIntent.FLAG_IMMUTABLE); + + return new NotificationCompat.Builder(this, SLEEP_TIMER_CHANNEL_ID) + .setContentTitle(getString(R.string.title_sleep_timer)) + .setContentText(String.format(getString(R.string.sleep_timer_notification_content), + countdownText)) + .setSmallIcon(android.R.drawable.ic_lock_idle_alarm) + .setContentIntent(pendingIntent) + .addAction(android.R.drawable.ic_menu_close_clear_cancel, + getString(R.string.sleep_timer_notification_cancel), cancelPendingIntent) + .build(); + } + + /** + * Creates ad registers a new {@link NotificationChannel}. + */ + private void createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + final NotificationChannel channel = new NotificationChannel(SLEEP_TIMER_CHANNEL_ID, + getString(R.string.sleep_timer_notification_channel), + NotificationManager.IMPORTANCE_DEFAULT); + mNotificationManager.createNotificationChannel(channel); + } + } + + /** + * Formats the provided time in milliseconds in a human readable format. + * + * @param millis The remaining time in milliseconds. + * + * @return The String representation of the milliseconds. + */ + private String formatTime(long millis) { + long minutes = TimeUnit.MILLISECONDS.toMinutes(millis); + long seconds = TimeUnit.MILLISECONDS.toSeconds(millis) - TimeUnit.MINUTES.toSeconds(minutes); + if (minutes > ONE_HOUR_IN_MINUTES) { + final long hours = minutes / ONE_HOUR_IN_MINUTES; + final long minutesCleaned = minutes % ONE_HOUR_IN_MINUTES; + return String.format(getString(R.string.sleep_timer_remaining_time_with_hour), + hours, minutesCleaned, seconds); + } + return String.format(getString(R.string.sleep_timer_remaining_time_without_hour), minutes, seconds); + } + + @Override + public void onDestroy() { + super.onDestroy(); + mHandler.removeCallbacks(mCountdownRunnable); + } +} diff --git a/app/src/main/res/drawable/helper_group_tile_background.xml b/app/src/main/res/drawable/helper_group_tile_background.xml new file mode 100644 index 0000000..2410c21 --- /dev/null +++ b/app/src/main/res/drawable/helper_group_tile_background.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/icn_sleep_timer.xml b/app/src/main/res/drawable/icn_sleep_timer.xml new file mode 100644 index 0000000..91711e6 --- /dev/null +++ b/app/src/main/res/drawable/icn_sleep_timer.xml @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..e0605d4 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,21 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/helper_group_tile_item.xml b/app/src/main/res/layout/helper_group_tile_item.xml new file mode 100644 index 0000000..91edbb7 --- /dev/null +++ b/app/src/main/res/layout/helper_group_tile_item.xml @@ -0,0 +1,37 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml new file mode 100644 index 0000000..951ced7 --- /dev/null +++ b/app/src/main/res/values-night/colors.xml @@ -0,0 +1,144 @@ + + + #91D5AC + #003921 + #055232 + #ADF2C7 + #B5CCBB + #213528 + #374B3E + #D0E8D6 + #A4CDDC + #043541 + #224C59 + #BFE9F9 + #FFB4AB + #690005 + #93000A + #FFDAD6 + #0F1511 + #DFE4DD + #0F1511 + #DFE4DD + #404942 + #C0C9C0 + #8A938B + #404942 + #000000 + #DFE4DD + #2C322D + #276A49 + #ADF2C7 + #002111 + #91D5AC + #055232 + #D0E8D6 + #0B1F14 + #B5CCBB + #374B3E + #BFE9F9 + #001F27 + #A4CDDC + #224C59 + #0F1511 + #353B36 + #0A0F0C + #171D19 + #1B211D + #262B27 + #303632 + #96D9B0 + #001B0D + #5C9E79 + #000000 + #B9D1BF + #061A0F + #809686 + #000000 + #A8D1E0 + #001921 + #6E97A5 + #000000 + #FFBAB1 + #370001 + #FF5449 + #000000 + #0F1511 + #DFE4DD + #0F1511 + #F7FCF5 + #404942 + #C4CDC4 + #9CA59D + #7D857E + #000000 + #DFE4DD + #262B27 + #075333 + #ADF2C7 + #001509 + #91D5AC + #003F25 + #D0E8D6 + #02150A + #B5CCBB + #263B2E + #BFE9F9 + #00141A + #A4CDDC + #0D3B47 + #0F1511 + #353B36 + #0A0F0C + #171D19 + #1B211D + #262B27 + #303632 + #EEFFF1 + #000000 + #96D9B0 + #000000 + #EEFFF1 + #000000 + #B9D1BF + #000000 + #F5FCFF + #000000 + #A8D1E0 + #000000 + #FFF9F9 + #000000 + #FFBAB1 + #000000 + #0F1511 + #DFE4DD + #0F1511 + #FFFFFF + #404942 + #F4FDF4 + #C4CDC4 + #C4CDC4 + #000000 + #DFE4DD + #000000 + #00311C + #B1F6CB + #000000 + #96D9B0 + #001B0D + #D5EDDB + #000000 + #B9D1BF + #061A0F + #C3EEFD + #000000 + #A8D1E0 + #001921 + #0F1511 + #353B36 + #0A0F0C + #171D19 + #1B211D + #262B27 + #303632 + \ No newline at end of file diff --git a/app/src/main/res/values-night/styles.xml b/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..b678d86 --- /dev/null +++ b/app/src/main/res/values-night/styles.xml @@ -0,0 +1,6 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..6059ec6 --- /dev/null +++ b/app/src/main/res/values-night/themes.xml @@ -0,0 +1,55 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/attrs_helper_group_button.xml b/app/src/main/res/values/attrs_helper_group_button.xml new file mode 100644 index 0000000..fbb319a --- /dev/null +++ b/app/src/main/res/values/attrs_helper_group_button.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..80479f9 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,155 @@ + + + #FF000000 + #FFFFFFFF + + #DADADA + #00796B + #DADADA + #00796B + #FF29B6F6 + #FF039BE5 + #FFBDBDBD + #FF757575 + + + #276A49 + #FFFFFF + #ADF2C7 + #002111 + #4E6355 + #FFFFFF + #D0E8D6 + #0B1F14 + #3C6471 + #FFFFFF + #BFE9F9 + #001F27 + #BA1A1A + #FFFFFF + #FFDAD6 + #410002 + #F6FBF4 + #171D19 + #F6FBF4 + #171D19 + #DCE5DC + #404942 + #717972 + #C0C9C0 + #000000 + #2C322D + #EDF2EB + #91D5AC + #ADF2C7 + #002111 + #91D5AC + #055232 + #D0E8D6 + #0B1F14 + #B5CCBB + #374B3E + #BFE9F9 + #001F27 + #A4CDDC + #224C59 + #D6DBD5 + #F6FBF4 + #FFFFFF + #F0F5EE + #EAEFE9 + #E4EAE3 + #DFE4DD + #004D2F + #FFFFFF + #40815E + #FFFFFF + #33473A + #FFFFFF + #647A6B + #FFFFFF + #1E4855 + #FFFFFF + #527A88 + #FFFFFF + #8C0009 + #FFFFFF + #DA342E + #FFFFFF + #F6FBF4 + #171D19 + #F6FBF4 + #171D19 + #DCE5DC + #3C453F + #59615A + #747D76 + #000000 + #2C322D + #EDF2EB + #91D5AC + #40815E + #FFFFFF + #256846 + #FFFFFF + #647A6B + #FFFFFF + #4C6153 + #FFFFFF + #527A88 + #FFFFFF + #39626F + #FFFFFF + #D6DBD5 + #F6FBF4 + #FFFFFF + #F0F5EE + #EAEFE9 + #E4EAE3 + #DFE4DD + #002816 + #FFFFFF + #004D2F + #FFFFFF + #12261B + #FFFFFF + #33473A + #FFFFFF + #002630 + #FFFFFF + #1E4855 + #FFFFFF + #4E0002 + #FFFFFF + #8C0009 + #FFFFFF + #F6FBF4 + #171D19 + #F6FBF4 + #000000 + #DCE5DC + #1E2620 + #3C453F + #3C453F + #000000 + #2C322D + #FFFFFF + #B6FBD0 + #004D2F + #FFFFFF + #00341E + #FFFFFF + #33473A + #FFFFFF + #1D3125 + #FFFFFF + #1E4855 + #FFFFFF + #00323D + #FFFFFF + #D6DBD5 + #F6FBF4 + #FFFFFF + #F0F5EE + #EAEFE9 + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..50cad2c --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,16 @@ + + Doerflinger-Helpers + + The action you would like to perform requires DeviceAdmin privileges, please grant them before continuing. + + + Sleep Timer + SleepTimer + Offers the possibility top set a sleep timer, after which the display will turn off + Device Admin privileges are needed for locking the screen after a specified timeout + In order for this feature to work, the app must be allowed to show a Notification + Sleep timer is scheduled. Your device will lock itself in %s + Cancel + %02d:%02d:%02d" + %02d:%02d" + \ No newline at end of file diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..ec9e4ae --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,6 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..2b095d9 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,53 @@ + + + + +