From a08328403be84d85c006f801169a3feed0d956a4 Mon Sep 17 00:00:00 2001 From: YuutaW <17158086+Trumeet@users.noreply.github.com> Date: Sun, 24 Feb 2019 11:59:17 -0800 Subject: First Commit Signed-off-by: YuutaW <17158086+Trumeet@users.noreply.github.com> --- .gitignore | 9 + app/.gitignore | 1 + app/build.gradle | 56 +++ app/proguard-rules.pro | 21 ++ app/src/main/AndroidManifest.xml | 58 +++ .../com/android/settings/widget/SwitchBar.java | 309 ++++++++++++++++ .../com/android/settings/widget/ToggleSwitch.java | 63 ++++ app/src/main/java/moe/yuuta/workmode/App.kt | 10 + .../yuuta/workmode/ApplicationPickerActivity.kt | 222 +++++++++++ .../main/java/moe/yuuta/workmode/MainActivity.kt | 406 +++++++++++++++++++++ .../java/moe/yuuta/workmode/MoreDetailsActivity.kt | 12 + app/src/main/java/moe/yuuta/workmode/Setup.kt | 64 ++++ .../java/moe/yuuta/workmode/access/AccessLayer.kt | 86 +++++ .../moe/yuuta/workmode/access/AccessorStarter.kt | 199 ++++++++++ .../workmode/access/ApplicationAccessorStarter.kt | 9 + .../java/moe/yuuta/workmode/access/DumpResult.kt | 7 + .../yuuta/workmode/access/ShellAccessorStarter.kt | 9 + .../moe/yuuta/workmode/access/WorkModeAccessor.kt | 317 ++++++++++++++++ .../main/java/moe/yuuta/workmode/async/Async.kt | 47 +++ .../main/java/moe/yuuta/workmode/async/Callback.kt | 6 + .../java/moe/yuuta/workmode/async/Stoppable.kt | 6 + .../moe/yuuta/workmode/async/StoppableGroup.kt | 28 ++ .../moe/yuuta/workmode/suspend/AsyncSuspender.kt | 27 ++ .../java/moe/yuuta/workmode/suspend/SuspendTile.kt | 30 ++ .../moe/yuuta/workmode/suspend/SuspendedApp.kt | 38 ++ .../java/moe/yuuta/workmode/suspend/Suspender.kt | 40 ++ .../moe/yuuta/workmode/suspend/data/ListMode.kt | 6 + .../java/moe/yuuta/workmode/suspend/data/Status.kt | 6 + .../workmode/suspend/data/SuspendedStorage.kt | 68 ++++ .../moe/yuuta/workmode/update/UpdateChecker.kt | 70 ++++ .../java/moe/yuuta/workmode/utils/BundleUtils.java | 89 +++++ .../yuuta/workmode/utils/ByteArraySerializer.kt | 26 ++ .../main/java/moe/yuuta/workmode/utils/Utils.kt | 127 +++++++ .../main/res/color/switchbar_switch_thumb_tint.xml | 17 + .../main/res/color/switchbar_switch_track_tint.xml | 18 + .../res/drawable-v24/ic_launcher_foreground.xml | 34 ++ app/src/main/res/drawable/ic_add_24dp.xml | 5 + app/src/main/res/drawable/ic_check_24dp.xml | 5 + .../main/res/drawable/ic_launcher_background.xml | 170 +++++++++ app/src/main/res/drawable/ic_work_24dp.xml | 5 + .../res/layout/activity_application_picker.xml | 33 ++ app/src/main/res/layout/activity_main.xml | 91 +++++ app/src/main/res/layout/item_application.xml | 31 ++ .../main/res/layout/item_application_select.xml | 42 +++ app/src/main/res/layout/switch_bar.xml | 51 +++ app/src/main/res/menu/menu_main.xml | 23 ++ app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../res/mipmap-anydpi-v26/ic_launcher_round.xml | 5 + app/src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 2963 bytes app/src/main/res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 4905 bytes app/src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 2060 bytes app/src/main/res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 2783 bytes app/src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 4490 bytes .../main/res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 6895 bytes app/src/main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 6387 bytes .../main/res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 10413 bytes app/src/main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 9128 bytes .../main/res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 15132 bytes app/src/main/res/values/attrs.xml | 8 + app/src/main/res/values/colors.xml | 8 + app/src/main/res/values/dimens.xml | 8 + app/src/main/res/values/strings.xml | 27 ++ app/src/main/res/values/styles.xml | 35 ++ app/src/main/res/xml/filepaths.xml | 6 + build.gradle | 29 ++ gradle.properties | 21 ++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 54329 bytes gradle/wrapper/gradle-wrapper.properties | 6 + gradlew | 172 +++++++++ gradlew.bat | 84 +++++ settings.gradle | 1 + 71 files changed, 3412 insertions(+) create mode 100644 .gitignore create mode 100644 app/.gitignore create mode 100644 app/build.gradle create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/com/android/settings/widget/SwitchBar.java create mode 100644 app/src/main/java/com/android/settings/widget/ToggleSwitch.java create mode 100644 app/src/main/java/moe/yuuta/workmode/App.kt create mode 100644 app/src/main/java/moe/yuuta/workmode/ApplicationPickerActivity.kt create mode 100644 app/src/main/java/moe/yuuta/workmode/MainActivity.kt create mode 100644 app/src/main/java/moe/yuuta/workmode/MoreDetailsActivity.kt create mode 100644 app/src/main/java/moe/yuuta/workmode/Setup.kt create mode 100644 app/src/main/java/moe/yuuta/workmode/access/AccessLayer.kt create mode 100644 app/src/main/java/moe/yuuta/workmode/access/AccessorStarter.kt create mode 100644 app/src/main/java/moe/yuuta/workmode/access/ApplicationAccessorStarter.kt create mode 100644 app/src/main/java/moe/yuuta/workmode/access/DumpResult.kt create mode 100644 app/src/main/java/moe/yuuta/workmode/access/ShellAccessorStarter.kt create mode 100644 app/src/main/java/moe/yuuta/workmode/access/WorkModeAccessor.kt create mode 100644 app/src/main/java/moe/yuuta/workmode/async/Async.kt create mode 100644 app/src/main/java/moe/yuuta/workmode/async/Callback.kt create mode 100644 app/src/main/java/moe/yuuta/workmode/async/Stoppable.kt create mode 100644 app/src/main/java/moe/yuuta/workmode/async/StoppableGroup.kt create mode 100644 app/src/main/java/moe/yuuta/workmode/suspend/AsyncSuspender.kt create mode 100644 app/src/main/java/moe/yuuta/workmode/suspend/SuspendTile.kt create mode 100644 app/src/main/java/moe/yuuta/workmode/suspend/SuspendedApp.kt create mode 100644 app/src/main/java/moe/yuuta/workmode/suspend/Suspender.kt create mode 100644 app/src/main/java/moe/yuuta/workmode/suspend/data/ListMode.kt create mode 100644 app/src/main/java/moe/yuuta/workmode/suspend/data/Status.kt create mode 100644 app/src/main/java/moe/yuuta/workmode/suspend/data/SuspendedStorage.kt create mode 100644 app/src/main/java/moe/yuuta/workmode/update/UpdateChecker.kt create mode 100644 app/src/main/java/moe/yuuta/workmode/utils/BundleUtils.java create mode 100644 app/src/main/java/moe/yuuta/workmode/utils/ByteArraySerializer.kt create mode 100644 app/src/main/java/moe/yuuta/workmode/utils/Utils.kt create mode 100644 app/src/main/res/color/switchbar_switch_thumb_tint.xml create mode 100644 app/src/main/res/color/switchbar_switch_track_tint.xml create mode 100644 app/src/main/res/drawable-v24/ic_launcher_foreground.xml create mode 100644 app/src/main/res/drawable/ic_add_24dp.xml create mode 100644 app/src/main/res/drawable/ic_check_24dp.xml create mode 100644 app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 app/src/main/res/drawable/ic_work_24dp.xml create mode 100644 app/src/main/res/layout/activity_application_picker.xml create mode 100644 app/src/main/res/layout/activity_main.xml create mode 100644 app/src/main/res/layout/item_application.xml create mode 100644 app/src/main/res/layout/item_application_select.xml create mode 100644 app/src/main/res/layout/switch_bar.xml create mode 100644 app/src/main/res/menu/menu_main.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/values/attrs.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/dimens.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/styles.xml create mode 100644 app/src/main/res/xml/filepaths.xml create mode 100644 build.gradle create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7ef5a0e --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +*.iml +.gradle +/local.properties +/.idea/ +.DS_Store +/build +/captures +.externalNativeBuild +.cxx diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..e7ddaed --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,56 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlin-android-extensions' +apply plugin: 'kotlin-kapt' +apply plugin: 'com.google.android.gms.oss-licenses-plugin' + +android { + compileSdkVersion 28 + defaultConfig { + applicationId "moe.yuuta.workmode" + minSdkVersion 28 + targetSdkVersion 28 + versionCode 1 + versionName "1.0" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + buildTypes { + release { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + lintOptions { + checkReleaseBuilds false + abortOnError false + } + kapt { + generateStubs = true + } + sourceSets { + main { + java.srcDirs += 'src/main/kotlin' + } + } +} + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + implementation 'androidx.appcompat:appcompat:1.0.2' + implementation 'androidx.core:core-ktx:1.0.1' + implementation 'androidx.constraintlayout:constraintlayout:1.1.3' + testImplementation 'junit:junit:4.12' + androidTestImplementation 'androidx.test.ext:junit:1.1.0' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1' + implementation 'eu.chainfire:librootjava:1.2.0' + implementation 'com.elvishew:xlog:1.6.1' + implementation 'eu.chainfire:libsuperuser:1.0.0.+' + implementation 'com.google.android.material:material:1.1.0-alpha03' + implementation 'androidx.recyclerview:recyclerview:1.0.0' + implementation 'com.google.android.gms:play-services-oss-licenses:16.0.2' +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..f1b4245 --- /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 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..435ee2b --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/android/settings/widget/SwitchBar.java b/app/src/main/java/com/android/settings/widget/SwitchBar.java new file mode 100644 index 0000000..fe82949 --- /dev/null +++ b/app/src/main/java/com/android/settings/widget/SwitchBar.java @@ -0,0 +1,309 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.settings.widget; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Rect; +import android.os.Parcel; +import android.os.Parcelable; +import android.text.SpannableStringBuilder; +import android.text.TextUtils; +import android.text.style.TextAppearanceSpan; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.TouchDelegate; +import android.view.View; +import android.view.ViewGroup; +import android.widget.CompoundButton; +import android.widget.LinearLayout; +import android.widget.Switch; +import android.widget.TextView; + +import androidx.annotation.ColorInt; +import androidx.annotation.StringRes; +import androidx.annotation.VisibleForTesting; + +import java.util.ArrayList; +import java.util.List; + +import moe.yuuta.workmode.R; + +public class SwitchBar extends LinearLayout implements CompoundButton.OnCheckedChangeListener { + + public interface OnSwitchChangeListener { + /** + * Called when the checked state of the Switch has changed. + * + * @param switchView The Switch view whose state has changed. + * @param isChecked The new checked state of switchView. + */ + void onSwitchChanged(Switch switchView, boolean isChecked); + } + + private static final int[] XML_ATTRIBUTES = { + R.attr.switchBarMarginStart, + R.attr.switchBarMarginEnd, + R.attr.switchBarBackgroundColor, + R.attr.switchBarBackgroundActivatedColor}; + + private final List mSwitchChangeListeners = new ArrayList<>(); + private final TextAppearanceSpan mSummarySpan; + + private ToggleSwitch mSwitch; + private TextView mTextView; + private String mLabel; + private String mSummary; + @ColorInt + private int mBackgroundColor; + @ColorInt + private int mBackgroundActivatedColor; + @StringRes + private int mOnTextId; + @StringRes + private int mOffTextId; + + public SwitchBar(Context context) { + this(context, null); + } + + public SwitchBar(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public SwitchBar(Context context, AttributeSet attrs, int defStyleAttr) { + this(context, attrs, defStyleAttr, 0); + } + + public SwitchBar(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + + LayoutInflater.from(context).inflate(R.layout.switch_bar, this); + + final TypedArray a = context.obtainStyledAttributes(attrs, XML_ATTRIBUTES); + int switchBarMarginStart = (int) a.getDimension(0, 0); + int switchBarMarginEnd = (int) a.getDimension(1, 0); + mBackgroundColor = a.getColor(2, 0); + mBackgroundActivatedColor = a.getColor(3, 0); + a.recycle(); + + mTextView = findViewById(R.id.switch_text); + mSummarySpan = new TextAppearanceSpan(getContext(), R.style.TextAppearance_Small_SwitchBar); + ViewGroup.MarginLayoutParams lp = (MarginLayoutParams) mTextView.getLayoutParams(); + lp.setMarginStart(switchBarMarginStart); + + mSwitch = findViewById(R.id.switch_widget); + // Prevent onSaveInstanceState() to be called as we are managing the state of the Switch + // on our own + mSwitch.setSaveEnabled(false); + + lp = (MarginLayoutParams) mSwitch.getLayoutParams(); + lp.setMarginEnd(switchBarMarginEnd); + setBackgroundColor(mBackgroundColor); + + setSwitchBarText(R.string.switch_on_text, R.string.switch_off_text); + + addOnSwitchChangeListener( + (switchView, isChecked) -> setTextViewLabelAndBackground(isChecked)); + + // Default is hide + setVisibility(View.GONE); + } + + public void setTextViewLabelAndBackground(boolean isChecked) { + mLabel = getResources().getString(isChecked ? mOnTextId : mOffTextId); + setBackgroundColor(isChecked ? mBackgroundActivatedColor : mBackgroundColor); + updateText(); + } + + public void setSwitchBarText(int onText, int offText) { + mOnTextId = onText; + mOffTextId = offText; + setTextViewLabelAndBackground(isChecked()); + } + + public void setSummary(String summary) { + mSummary = summary; + updateText(); + } + + private void updateText() { + if (TextUtils.isEmpty(mSummary)) { + mTextView.setText(mLabel); + return; + } + final SpannableStringBuilder ssb = new SpannableStringBuilder(mLabel).append('\n'); + final int start = ssb.length(); + ssb.append(mSummary); + ssb.setSpan(mSummarySpan, start, ssb.length(), 0); + mTextView.setText(ssb); + } + + public void setChecked(boolean checked) { + setTextViewLabelAndBackground(checked); + mSwitch.setChecked(checked); + } + + public void setCheckedInternal(boolean checked) { + setTextViewLabelAndBackground(checked); + mSwitch.setCheckedInternal(checked); + } + + public boolean isChecked() { + return mSwitch.isChecked(); + } + + public void setEnabled(boolean enabled) { + super.setEnabled(enabled); + mTextView.setEnabled(enabled); + mSwitch.setEnabled(enabled); + } + + @VisibleForTesting + View getDelegatingView() { + return mSwitch; + } + + public final ToggleSwitch getSwitch() { + return mSwitch; + } + + public void show() { + if (!isShowing()) { + setVisibility(View.VISIBLE); + mSwitch.setOnCheckedChangeListener(this); + // Make the entire bar work as a switch + post(() -> setTouchDelegate( + new TouchDelegate(new Rect(0, 0, getWidth(), getHeight()), + getDelegatingView()))); + } + } + + public void hide() { + if (isShowing()) { + setVisibility(View.GONE); + mSwitch.setOnCheckedChangeListener(null); + } + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + if ((w > 0) && (h > 0)) { + setTouchDelegate(new TouchDelegate(new Rect(0, 0, w, h), + getDelegatingView())); + } + } + + public boolean isShowing() { + return (getVisibility() == View.VISIBLE); + } + + public void propagateChecked(boolean isChecked) { + final int count = mSwitchChangeListeners.size(); + for (int n = 0; n < count; n++) { + mSwitchChangeListeners.get(n).onSwitchChanged(mSwitch, isChecked); + } + } + + @Override + public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + propagateChecked(isChecked); + } + + public void addOnSwitchChangeListener(OnSwitchChangeListener listener) { + if (mSwitchChangeListeners.contains(listener)) { + throw new IllegalStateException("Cannot add twice the same OnSwitchChangeListener"); + } + mSwitchChangeListeners.add(listener); + } + + public void removeOnSwitchChangeListener(OnSwitchChangeListener listener) { + if (!mSwitchChangeListeners.contains(listener)) { + return; + // throw new IllegalStateException("Cannot remove OnSwitchChangeListener"); + } + mSwitchChangeListeners.remove(listener); + } + + static class SavedState extends BaseSavedState { + boolean checked; + boolean visible; + + SavedState(Parcelable superState) { + super(superState); + } + + /** + * Constructor called from {@link #CREATOR} + */ + private SavedState(Parcel in) { + super(in); + checked = (Boolean) in.readValue(null); + visible = (Boolean) in.readValue(null); + } + + @Override + public void writeToParcel(Parcel out, int flags) { + super.writeToParcel(out, flags); + out.writeValue(checked); + out.writeValue(visible); + } + + @Override + public String toString() { + return "SwitchBar.SavedState{" + + Integer.toHexString(System.identityHashCode(this)) + + " checked=" + checked + + " visible=" + visible + "}"; + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + public SavedState createFromParcel(Parcel in) { + return new SavedState(in); + } + + public SavedState[] newArray(int size) { + return new SavedState[size]; + } + }; + } + + @Override + public Parcelable onSaveInstanceState() { + Parcelable superState = super.onSaveInstanceState(); + + SavedState ss = new SavedState(superState); + ss.checked = mSwitch.isChecked(); + ss.visible = isShowing(); + return ss; + } + + @Override + public void onRestoreInstanceState(Parcelable state) { + SavedState ss = (SavedState) state; + + super.onRestoreInstanceState(ss.getSuperState()); + + mSwitch.setCheckedInternal(ss.checked); + setTextViewLabelAndBackground(ss.checked); + setVisibility(ss.visible ? View.VISIBLE : View.GONE); + mSwitch.setOnCheckedChangeListener(ss.visible ? this : null); + + requestLayout(); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/android/settings/widget/ToggleSwitch.java b/app/src/main/java/com/android/settings/widget/ToggleSwitch.java new file mode 100644 index 0000000..3c0c36e --- /dev/null +++ b/app/src/main/java/com/android/settings/widget/ToggleSwitch.java @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2013 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.settings.widget; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.Switch; + +public class ToggleSwitch extends Switch { + + private ToggleSwitch.OnBeforeCheckedChangeListener mOnBeforeListener; + + public interface OnBeforeCheckedChangeListener { + boolean onBeforeCheckedChanged(ToggleSwitch toggleSwitch, boolean checked); + } + + public ToggleSwitch(Context context) { + super(context); + } + + public ToggleSwitch(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public ToggleSwitch(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public ToggleSwitch(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + public void setOnBeforeCheckedChangeListener(OnBeforeCheckedChangeListener listener) { + mOnBeforeListener = listener; + } + + @Override + public void setChecked(boolean checked) { + if (mOnBeforeListener != null + && mOnBeforeListener.onBeforeCheckedChanged(this, checked)) { + return; + } + super.setChecked(checked); + } + + public void setCheckedInternal(boolean checked) { + super.setChecked(checked); + } +} \ No newline at end of file diff --git a/app/src/main/java/moe/yuuta/workmode/App.kt b/app/src/main/java/moe/yuuta/workmode/App.kt new file mode 100644 index 0000000..3735a3e --- /dev/null +++ b/app/src/main/java/moe/yuuta/workmode/App.kt @@ -0,0 +1,10 @@ +package moe.yuuta.workmode + +import android.app.Application + +class App : Application() { + override fun onCreate() { + super.onCreate() + Setup.initLogs(Setup.getLogsPath(this).absolutePath) + } +} \ No newline at end of file diff --git a/app/src/main/java/moe/yuuta/workmode/ApplicationPickerActivity.kt b/app/src/main/java/moe/yuuta/workmode/ApplicationPickerActivity.kt new file mode 100644 index 0000000..a5e3d0c --- /dev/null +++ b/app/src/main/java/moe/yuuta/workmode/ApplicationPickerActivity.kt @@ -0,0 +1,222 @@ +package moe.yuuta.workmode + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import android.graphics.drawable.Drawable +import android.os.Bundle +import android.util.LruCache +import android.view.LayoutInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.* +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.elvishew.xlog.Logger +import com.elvishew.xlog.XLog +import com.google.android.material.floatingactionbutton.FloatingActionButton +import moe.yuuta.workmode.async.Async +import moe.yuuta.workmode.async.Callback +import moe.yuuta.workmode.async.Runnable +import moe.yuuta.workmode.async.StoppableGroup +import moe.yuuta.workmode.utils.Utils +import java.util.stream.Collectors + +class ApplicationPickerActivity : AppCompatActivity() { + companion object { + const val EXTRA_SELECTED_PACKAGE_NAME = "moe.yuuta.workmode.ApplicationPickerActivity.EXTRA_SELECTED_PACKAGE_NAME" + } + private val logger: Logger = XLog.tag("ApplicationPickerActivity").build() + + private lateinit var mAdapter: Adapter + private val mStoppableGroup: StoppableGroup = StoppableGroup() + private lateinit var mProgressBar: ProgressBar + private lateinit var fab: FloatingActionButton + + private fun setResultAndFinish(packageNames: Array?) { + setResult(if (packageNames == null) Activity.RESULT_CANCELED else Activity.RESULT_OK, + Intent().putExtra(EXTRA_SELECTED_PACKAGE_NAME, packageNames ?: arrayOf())) + finish() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_application_picker) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + val recyclerView: RecyclerView = findViewById(R.id.recycler_apps) + fab = findViewById(R.id.fab_ok) + mProgressBar = findViewById(R.id.progress_load) + recyclerView.layoutManager = LinearLayoutManager(this, RecyclerView.VERTICAL, false) + mAdapter = Adapter() + recyclerView.adapter = mAdapter + fab.setOnClickListener { + setResultAndFinish(mAdapter.checked.toTypedArray()) + } + load() + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + android.R.id.home -> { + onBackPressed() + true + } + else -> super.onOptionsItemSelected(item) + } + } + + private fun load() { + mStoppableGroup.add(Async.beginTask(object : Runnable> { + override fun run(): List { + val selected = intent.getStringArrayExtra(EXTRA_SELECTED_PACKAGE_NAME) ?: arrayOf() + return packageManager.getInstalledApplications(0) + .stream() + .filter(Utils.buildGeneralApplicationInfoFilter(this@ApplicationPickerActivity)) + .sorted(ApplicationInfo.DisplayNameComparator(packageManager)) + .map { + return@map SelectedApp(it.packageName, selected.contains(it.packageName)) + } + .collect(Collectors.toList()) + } + }, object : Callback> { + override fun onStart() { + mProgressBar.visibility = View.VISIBLE + fab.visibility = View.GONE + } + + override fun onStop(success: Boolean, result: List?, e: Throwable?) { + mProgressBar.visibility = View.GONE + fab.visibility = View.VISIBLE + if (success && result != null) { + display(result) + } else { + Toast.makeText(this@ApplicationPickerActivity, + R.string.error_load_applications, Toast.LENGTH_LONG) + .show() + if (e != null) { + logger.e("Load applications", e) + } else { + logger.e("Cannot load applications (no stacktrace)") + } + // Not sure if the toast will dismiss immediately + // setResultAndFinish() + } + } + })) + } + + private fun display(result: List) { + val diff = DiffUtil.calculateDiff(object : DiffUtil.Callback() { + override fun getOldListSize(): Int = mAdapter.itemCount + + override fun getNewListSize(): Int = result.size + + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = + mAdapter.data[oldItemPosition]::class.java.name == + result[newItemPosition]::class.java.name + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = + mAdapter.data[oldItemPosition] == + result[newItemPosition] + }) + mAdapter.data = result + diff.dispatchUpdatesTo(mAdapter) + } + + override fun onDestroy() { + mStoppableGroup.stop() + mAdapter.destroy() + super.onDestroy() + } + + private class Adapter : RecyclerView.Adapter() { + private val mIconMemoryCaches: LruCache = + LruCache(Runtime.getRuntime().maxMemory().toInt() / 5) + private val mStoppableGroup: StoppableGroup = StoppableGroup() + internal var data: List = listOf() + internal val checked: MutableSet = mutableSetOf() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH = + VH(LayoutInflater.from(parent.context).inflate(R.layout.item_application_select, parent, false)) + + override fun getItemCount(): Int = data.size + + override fun onBindViewHolder(holder: VH, position: Int) { + val context = holder.itemView.context + val packageInfo = data[position] + val icon = getIconFromMemoryCache(packageInfo.packageName) + if (icon != null) { + holder.icon.setImageDrawable(icon) + } else { + loadIcon(packageInfo.packageName, holder.itemView.context, holder.icon) + } + holder.title.text = context + .packageManager + .getApplicationLabel( + context.packageManager.getApplicationInfo(packageInfo.packageName, 0) + ) + if (packageInfo.selected) checked.add(packageInfo.packageName) + else checked.remove(packageInfo.packageName) + holder.checkBox.isChecked = checked.contains(packageInfo.packageName) + holder.checkBox.setOnClickListener { + val selected = holder.checkBox.isChecked + if (selected) checked.add(packageInfo.packageName) + else checked.remove(packageInfo.packageName) + } + } + + class VH(itemView: View) : RecyclerView.ViewHolder(itemView) { + internal val icon: ImageView = itemView.findViewById(android.R.id.icon) + internal val title: TextView = itemView.findViewById(android.R.id.title) + internal val checkBox: CheckBox = itemView.findViewById(android.R.id.checkbox) + } + + private fun addDrawableToMemoryCache(pkg: String?, icon: Drawable) { + if (getIconFromMemoryCache(pkg) == null) { + mIconMemoryCaches.put(pkg ?: "", icon) + } + } + + private fun getIconFromMemoryCache(pkg: String?): Drawable? { + return mIconMemoryCaches.get(pkg ?: "") + } + + private fun loadIcon(pkg: String, context: Context, imageView: ImageView) { + mStoppableGroup.add(Async.beginTask(object : Runnable { + override fun run(): Drawable { + var icon: Drawable? + try { + icon = context.packageManager + .getApplicationIcon(pkg) + } catch (ignore: PackageManager.NameNotFoundException) { + icon = null + } + if (icon == null) { + icon = ContextCompat.getDrawable(context, android.R.mipmap.sym_def_app_icon)!! + } + addDrawableToMemoryCache(pkg, icon) + return icon + } + }, object : Callback { + override fun onStop(success: Boolean, result: Drawable?, e: Throwable?) { + if (success && result != null) imageView.setImageDrawable(result) + } + })) + } + + fun destroy() { + mStoppableGroup.stop() + } + } +} + +private data class SelectedApp( + val packageName: String, + val selected: Boolean +) \ No newline at end of file diff --git a/app/src/main/java/moe/yuuta/workmode/MainActivity.kt b/app/src/main/java/moe/yuuta/workmode/MainActivity.kt new file mode 100644 index 0000000..b1d425b --- /dev/null +++ b/app/src/main/java/moe/yuuta/workmode/MainActivity.kt @@ -0,0 +1,406 @@ +package moe.yuuta.workmode + +import android.app.Activity +import android.content.* +import android.content.pm.PackageManager +import android.graphics.drawable.Drawable +import android.net.Uri +import android.os.Bundle +import android.service.quicksettings.TileService +import android.util.Log +import android.util.LruCache +import android.view.* +import android.widget.* +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.android.settings.widget.SwitchBar +import com.elvishew.xlog.Logger +import com.elvishew.xlog.XLog +import com.google.android.gms.oss.licenses.OssLicensesMenuActivity +import com.google.android.material.floatingactionbutton.FloatingActionButton +import com.google.android.material.snackbar.Snackbar +import com.google.android.material.tabs.TabLayout +import moe.yuuta.workmode.access.AccessorStarter +import moe.yuuta.workmode.async.* +import moe.yuuta.workmode.suspend.AsyncSuspender +import moe.yuuta.workmode.suspend.SuspendTile +import moe.yuuta.workmode.suspend.data.ListMode +import moe.yuuta.workmode.suspend.data.Status +import moe.yuuta.workmode.suspend.data.SuspendedStorage +import moe.yuuta.workmode.update.Update +import moe.yuuta.workmode.update.UpdateChecker +import moe.yuuta.workmode.utils.Utils +import java.util.stream.Collectors + +class MainActivity : AppCompatActivity(), SwitchBar.OnSwitchChangeListener, View.OnClickListener { + private val logger: Logger = XLog.tag("MainActivity").build() + + companion object { + const val RC_PICK = 1 + } + + private lateinit var mAdapter: Adapter + + private lateinit var switchBar: SwitchBar + private lateinit var progressBar: ProgressBar + private lateinit var tabLayout: TabLayout + private lateinit var welcomeTip: TextView + + private val mStoppableGroup: StoppableGroup = StoppableGroup() + private var mSortDisplayStoppable: Stoppable? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + switchBar = findViewById(R.id.switch_bar) + switchBar.show() + welcomeTip = findViewById(R.id.welcome_tip) + progressBar = findViewById(R.id.progress_apply) + tabLayout = findViewById(R.id.tab) + tabLayout.addTab(tabLayout.newTab().setTag("blacklist").setText(R.string.blacklist)) + tabLayout.addTab(tabLayout.newTab().setTag("whitelist").setText(R.string.whitelist)) + val fab: FloatingActionButton = findViewById(R.id.fab_add) + fab.setOnClickListener(this) + val recyclerView: RecyclerView = findViewById(R.id.recycler_apps) + recyclerView.layoutManager = LinearLayoutManager(this, RecyclerView.VERTICAL, false) + mAdapter = Adapter() + recyclerView.adapter = mAdapter + displayUI() + val filter = IntentFilter(AccessorStarter.ACTION_UPDATE_UI_STATE) + filter.addAction(AccessorStarter.ACTION_UPDATE_UI_PROGRESS) + registerReceiver(mUIUpdateReceiver, filter) + scheduleUpdateChecking() + setProgressUI(false) + } + + override fun onSwitchChanged(switchView: Switch?, isChecked: Boolean) { + SuspendedStorage(this).setStatus(if (isChecked) Status.ON else Status.OFF) + scheduleApply() + } + + /** + * Apply settings which are stored in SuspendedStorage to OS + */ + private fun scheduleApply() { + mStoppableGroup.add(AsyncSuspender(this).applyFromSettings(object : Callback { + override fun onStart() { + setProgressUI(true) + } + + override fun onStop(success: Boolean, result: Unit?, e: Throwable?) { + setProgressUI(false) + displayUI() + if (!success) { + logger.e("Unable scheduleApply settings", e) + Toast.makeText(this@MainActivity, R.string.error_apply, Toast.LENGTH_LONG).show() + } + } + })) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + when (requestCode) { + RC_PICK -> { + if (resultCode == Activity.RESULT_OK && data != null && data.hasExtra(ApplicationPickerActivity.EXTRA_SELECTED_PACKAGE_NAME)) { + val newSet = data.getStringArrayExtra(ApplicationPickerActivity.EXTRA_SELECTED_PACKAGE_NAME).toSet() + logger.d("AR() $newSet") + SuspendedStorage(this).setList(newSet) + scheduleApply() + } + } + } + } + + private fun setProgressUI(showProgress: Boolean) { + TileService.requestListeningState(this, ComponentName(this, SuspendTile::class.java)) + Utils.setViewTreeEnable(findViewById(android.R.id.content), !showProgress) + Utils.makeTabLayoutDisable(tabLayout, !showProgress) + progressBar.visibility = if (showProgress) View.VISIBLE else View.GONE + } + + override fun onDestroy() { + mStoppableGroup.stop() + mAdapter.destroy() + if (mSortDisplayStoppable != null) (mSortDisplayStoppable as Stoppable).stop() + unregisterReceiver(mUIUpdateReceiver) + super.onDestroy() + } + + /** + * Display the data from SuspendedStorage to UI + */ + private fun displayUI() { + switchBar.removeOnSwitchChangeListener(this) + switchBar.isChecked = SuspendedStorage(this).getStatus() == Status.ON + switchBar.addOnSwitchChangeListener(this) + tabLayout.removeOnTabSelectedListener(mSwitchListModeListener) + tabLayout.getTabAt( + when (SuspendedStorage(this).getListMode()) { + ListMode.BLACKLIST -> 0 + ListMode.WHITELIST -> 1 + } + )!!.select() + tabLayout.addOnTabSelectedListener(mSwitchListModeListener) + if (mSortDisplayStoppable != null) { + val stoppable = mSortDisplayStoppable as Stoppable + stoppable.stop() + mSortDisplayStoppable = null + } + mSortDisplayStoppable = Async.beginTask(object : Runnable> { + override fun run(): List? { + val sCollator = java.text.Collator.getInstance() + return SuspendedStorage(this@MainActivity).getList() + .stream() + .sorted { o1, o2 -> + return@sorted sCollator.compare(packageManager.getApplicationLabel(packageManager.getApplicationInfo(o1, 0)).toString() + , packageManager.getApplicationLabel(packageManager.getApplicationInfo(o2, 0))) + } + .collect(Collectors.toList()) + } + }, object : Callback> { + override fun onStop(success: Boolean, result: List?, e: Throwable?) { + if (result != null) { + val diff = DiffUtil.calculateDiff(object : DiffUtil.Callback() { + override fun getOldListSize(): Int = mAdapter.itemCount + + override fun getNewListSize(): Int = result.size + + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = + mAdapter.data[oldItemPosition]::class.java.name == + result[newItemPosition]::class.java.name + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = + mAdapter.data[oldItemPosition] == + result[newItemPosition] + }) + mAdapter.data = result + diff.dispatchUpdatesTo(mAdapter) + if (result.isEmpty()) { + welcomeTip.setText(when (SuspendedStorage(this@MainActivity).getListMode()) { + ListMode.BLACKLIST -> R.string.blacklist_welcome + ListMode.WHITELIST -> R.string.whitelist_welcome + }) + welcomeTip.visibility = View.VISIBLE + } else { + welcomeTip.visibility = View.GONE + } + } else { + if (e == null) logger.e("Unable to sort data") + else logger.e("Unable to sort data with error", e) + } + } + }) + } + + private val mUIUpdateReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent == null) return + when (intent.action) { + AccessorStarter.ACTION_UPDATE_UI_STATE -> { + displayUI() + } + AccessorStarter.ACTION_UPDATE_UI_PROGRESS -> { + logger.d("Updating progress from receiver") + setProgressUI(intent.getBooleanExtra(AccessorStarter.EXTRA_SHOW_PROGRESS, false)) + } + } + } + } + + private fun scheduleUpdateChecking() { + mStoppableGroup.add(Async.beginTask(UpdateChecker(), object : Callback { + override fun onStop(success: Boolean, result: Update?, e: Throwable?) { + if (result == null) return + if (result.version <= BuildConfig.VERSION_CODE) return + if (!shouldOpenGooglePlay() && !result.altUrlEnabled && !result.altUrlForce) return + Snackbar.make(findViewById(android.R.id.content), + getString(R.string.update_available, + result.name), + Snackbar.LENGTH_LONG) + .setAction(R.string.view) { + val url = if (shouldOpenGooglePlay() && !result.altUrlForce) + "https://play.google.com/store/apps/details?id=${BuildConfig.APPLICATION_ID}" + else result.altUrl + try { + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + } catch (ignored: ActivityNotFoundException) {} + } + .show() + } + + private fun shouldOpenGooglePlay(): Boolean = + "com.android.vending" == this@MainActivity.packageManager.getInstallerPackageName(BuildConfig.APPLICATION_ID) + + })) + } + + private val mSwitchListModeListener = object : TabLayout.OnTabSelectedListener { + override fun onTabReselected(tab: TabLayout.Tab?) { + } + + override fun onTabUnselected(tab: TabLayout.Tab?) { + } + + override fun onTabSelected(tab: TabLayout.Tab) { + when (tab.tag) { + "blacklist" -> { + AlertDialog.Builder(this@MainActivity) + .setTitle(R.string.blacklist_toggle_title) + .setMessage(R.string.blacklist_toggle_information) + .setCancelable(false) + .setNegativeButton(android.R.string.cancel) { _, _ -> + tabLayout.removeOnTabSelectedListener(this) + tabLayout.getTabAt(1)?.select() + tabLayout.addOnTabSelectedListener(this) + } + .setPositiveButton(android.R.string.ok) { _, _ -> + SuspendedStorage(this@MainActivity).setListMode(ListMode.BLACKLIST) + scheduleApply() + } + .show() + } + "whitelist" -> { + AlertDialog.Builder(this@MainActivity) + .setTitle(R.string.whitelist_toggle_title) + .setMessage(R.string.whitelist_toggle_information) + .setCancelable(false) + .setNegativeButton(android.R.string.cancel) { _, _ -> + tabLayout.removeOnTabSelectedListener(this) + tabLayout.getTabAt(0)?.select() + tabLayout.addOnTabSelectedListener(this) + } + .setPositiveButton(android.R.string.ok) { _, _ -> + SuspendedStorage(this@MainActivity).setListMode(ListMode.WHITELIST) + scheduleApply() + } + .show() + } + } + } + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.menu_main, menu) + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_get_logs -> { + return try { + startActivity(Intent.createChooser(Setup.buildShareLogsIntent(this), + getString(R.string.get_logs))) + true + } catch (e: Exception) { + try { + logger.e("Share logs", e) + } catch (ignored: Exception) {} + System.err.println("Unable to share logs, ${Log.getStackTraceString(e)}") + true + } + } + R.id.action_feedback -> { + val intent = Intent(Intent.ACTION_SENDTO) + .setData(Uri.parse("mailto:")) + intent.putExtra(Intent.EXTRA_SUBJECT, getString(R.string.feedback_subject)) + intent.putExtra(Intent.EXTRA_EMAIL, arrayOf("android-apps@yuuta.moe")) + startActivity(Intent.createChooser(intent, getString(R.string.feedback))) + return true + } + R.id.action_check_update -> { + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://play.google.com/store/apps/details?id=${BuildConfig.APPLICATION_ID}"))) + return true + } + R.id.action_oss -> { + startActivity(Intent(this, OssLicensesMenuActivity::class.java)) + return true + } + else -> return super.onOptionsItemSelected(item) + } + } + + override fun onClick(v: View?) { + if (v == null) return + when (v.id) { + R.id.fab_add -> { + startActivityForResult(Intent(this, ApplicationPickerActivity::class.java) + .putExtra(ApplicationPickerActivity.EXTRA_SELECTED_PACKAGE_NAME, + SuspendedStorage(this).getList().toTypedArray()), RC_PICK) + } + } + } +} + +private class Adapter : RecyclerView.Adapter() { + private val mIconMemoryCaches: LruCache = + LruCache(Runtime.getRuntime().maxMemory().toInt() / 5) + private val mStoppableGroup: StoppableGroup = StoppableGroup() + internal var data: List = listOf() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH = + VH(LayoutInflater.from(parent.context).inflate(R.layout.item_application, parent, false)) + + override fun getItemCount(): Int = data.size + + override fun onBindViewHolder(holder: VH, position: Int) { + val context = holder.itemView.context + val packageName = data[position] + val icon = getIconFromMemoryCache(packageName) + if (icon != null) { + holder.icon.setImageDrawable(icon) + } else { + loadIcon(packageName, holder.itemView.context, holder.icon) + } + holder.title.text = context + .packageManager + .getApplicationLabel( + context.packageManager.getApplicationInfo(packageName, 0) + ) + } + + class VH(itemView: View) : RecyclerView.ViewHolder(itemView) { + internal val icon: ImageView = itemView.findViewById(android.R.id.icon) + internal val title: TextView = itemView.findViewById(android.R.id.title) + } + + private fun addDrawableToMemoryCache(pkg: String?, icon: Drawable) { + if (getIconFromMemoryCache(pkg) == null) { + mIconMemoryCaches.put(pkg ?: "", icon) + } + } + + private fun getIconFromMemoryCache(pkg: String?): Drawable? { + return mIconMemoryCaches.get(pkg ?: "") + } + + private fun loadIcon(pkg: String, context: Context, imageView: ImageView) { + mStoppableGroup.add(Async.beginTask(object : Runnable { + override fun run(): Drawable { + var icon: Drawable? + try { + icon = context.packageManager + .getApplicationIcon(pkg) + } catch (ignore: PackageManager.NameNotFoundException) { + icon = null + } + if (icon == null) { + icon = ContextCompat.getDrawable(context, android.R.mipmap.sym_def_app_icon)!! + } + addDrawableToMemoryCache(pkg, icon) + return icon + } + }, object : Callback { + override fun onStop(success: Boolean, result: Drawable?, e: Throwable?) { + if (success && result != null) imageView.setImageDrawable(result) + } + })) + } + + fun destroy() { + mStoppableGroup.stop() + } +} \ No newline at end of file diff --git a/app/src/main/java/moe/yuuta/workmode/MoreDetailsActivity.kt b/app/src/main/java/moe/yuuta/workmode/MoreDetailsActivity.kt new file mode 100644 index 0000000..254e130 --- /dev/null +++ b/app/src/main/java/moe/yuuta/workmode/MoreDetailsActivity.kt @@ -0,0 +1,12 @@ +package moe.yuuta.workmode + +import android.content.Intent +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity + +class MoreDetailsActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + startActivity(Intent(this, MainActivity::class.java)) + } +} \ No newline at end of file diff --git a/app/src/main/java/moe/yuuta/workmode/Setup.kt b/app/src/main/java/moe/yuuta/workmode/Setup.kt new file mode 100644 index 0000000..fd75ff0 --- /dev/null +++ b/app/src/main/java/moe/yuuta/workmode/Setup.kt @@ -0,0 +1,64 @@ +package moe.yuuta.workmode + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.core.content.FileProvider +import com.elvishew.xlog.LogConfiguration +import com.elvishew.xlog.LogUtils +import com.elvishew.xlog.XLog +import com.elvishew.xlog.interceptor.BlacklistTagsFilterInterceptor +import com.elvishew.xlog.printer.AndroidPrinter +import com.elvishew.xlog.printer.file.FilePrinter +import com.elvishew.xlog.printer.file.clean.FileLastModifiedCleanStrategy +import moe.yuuta.workmode.utils.Utils +import java.io.File +import java.text.SimpleDateFormat +import java.util.* + +object Setup { + fun getLogsPath(context: Context): File = + File(context.applicationContext.dataDir.absolutePath + "/logs") + + fun initLogs(logsPath: String) { + val config = LogConfiguration.Builder() + .tag("WorkMode") + .addInterceptor(BlacklistTagsFilterInterceptor("FCore")) + .addObjectFormatter(Bundle::class.java) { + return@addObjectFormatter Utils.dumpExtras(it) + } + .build() + + val androidPrinter = AndroidPrinter() + val filePrinter = FilePrinter + .Builder(logsPath) + .cleanStrategy(FileLastModifiedCleanStrategy(1000 * 60 * 60 * 24 * 5)) + .build() + + XLog.init(config, androidPrinter, filePrinter) + } + + internal fun buildShareLogsIntent(context: Context): Intent { + val zipFile = File("${context.externalCacheDir.absolutePath}/logs/logs-" + + "${SimpleDateFormat("yyyy-mm-dd-H-m-s", Locale.US).format(Date())}.zip") + LogUtils.compress(Setup.getLogsPath(context).absolutePath, + zipFile.absolutePath) + val fileUri = FileProvider.getUriForFile( + context, + BuildConfig.APPLICATION_ID + ".fileprovider", + zipFile) + if (fileUri == null || !zipFile.exists()) { + throw NullPointerException() + } + val intent = Intent() + intent.action = Intent.ACTION_SEND + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + var type = context.contentResolver.getType(fileUri) + if (type == null || type.trim().equals("")) { + type = "application/zip" + } + intent.type = type + intent.putExtra(Intent.EXTRA_STREAM, fileUri) + return intent + } +} \ No newline at end of file diff --git a/app/src/main/java/moe/yuuta/workmode/access/AccessLayer.kt b/app/src/main/java/moe/yuuta/workmode/access/AccessLayer.kt new file mode 100644 index 0000000..78ef5dd --- /dev/null +++ b/app/src/main/java/moe/yuuta/workmode/access/AccessLayer.kt @@ -0,0 +1,86 @@ +package moe.yuuta.workmode.access + +import android.content.Context +import android.content.pm.LauncherApps +import android.content.pm.PackageManager +import android.os.Bundle +import android.os.PersistableBundle +import android.os.Process +import android.os.UserHandle +import android.system.Os +import moe.yuuta.workmode.BuildConfig +import java.lang.reflect.Field +import java.lang.reflect.Method + +/** + * An layer to access package suspending related APIs, it is a low-level layer which is used to call System APIs directly. + * + * TODO: Multi-user support + */ +internal class AccessLayer(private val mContext: Context) { + private val mPM: PackageManager = mContext.packageManager + + fun setPackagesSuspended(packageNames: Array, suspended: Boolean, + appExtras: PersistableBundle, launcherExtras: PersistableBundle, + dialogMessage: String): Array { + // ApplicationPackageManager ALWAYS uses context.getOpPackageName() as the argument "callingPackage" + // My callingPackage MUSTN'T equals to 'android' + // If we are using packageName of 'android', system will show disabled + // by admin dialog instead of suspended dialog + // F**k Google + val func: Method = Class.forName("android.content.pm.IPackageManager") + .getDeclaredMethod("setPackagesSuspendedAsUser", + Array::class.java, + Boolean::class.java, + PersistableBundle::class.java, + PersistableBundle::class.java, + String::class.java, + String::class.java, + Int::class.java) + + // It's an unstable design + val iPM: Field = mPM::class.java.getDeclaredField("mPM") + iPM.isAccessible = true + + return func.invoke(iPM.get(mPM), + packageNames, + suspended, + appExtras, + launcherExtras, + dialogMessage, + BuildConfig.APPLICATION_ID, + UserHandle.getUserHandleForUid(mPM.getPackageUid(mContext.packageName, 0)).hashCode()) as Array + } + + /** + * This method will SET your UID and you WON'T BE ABLE TO GO BACK. + * Create a new process and access it. + */ + fun getSuspendedPackageAppExtras(packageName: String): PersistableBundle? { + Os.setuid(mPM.getPackageUid(packageName, 0)) + // ApplicationPackageManager ALWAYS uses context.getOpPackageName() as the package name + // F**k Google + val func: Method = Class.forName("android.content.pm.IPackageManager") + .getDeclaredMethod("getSuspendedPackageAppExtras", + String::class.java, + Int::class.java) + + // It's an unstable design + val iPM: Field = mPM::class.java.getDeclaredField("mPM") + iPM.isAccessible = true + + return func.invoke(iPM.get(mPM), + packageName, + UserHandle.getUserHandleForUid(mPM.getPackageUid(packageName, 0)).hashCode()) as PersistableBundle? + } + + @Throws(PackageManager.NameNotFoundException::class) + fun isPackageSuspended(packageName: String): Boolean { + val func: Method = PackageManager::class.java.getDeclaredMethod("isPackageSuspended", + String::class.java) + return func.invoke(mPM, packageName) as Boolean + } + + fun getSuspendedPackageLauncherExtras(packageName: String): Bundle? = + mContext.getSystemService(LauncherApps::class.java).getSuspendedPackageLauncherExtras(packageName, Process.myUserHandle()) +} \ No newline at end of file diff --git a/app/src/main/java/moe/yuuta/workmode/access/AccessorStarter.kt b/app/src/main/java/moe/yuuta/workmode/access/AccessorStarter.kt new file mode 100644 index 0000000..c8aae67 --- /dev/null +++ b/app/src/main/java/moe/yuuta/workmode/access/AccessorStarter.kt @@ -0,0 +1,199 @@ +package moe.yuuta.workmode.access + +import android.content.Context +import android.os.Bundle +import android.os.Parcel +import android.os.PersistableBundle +import com.elvishew.xlog.Logger +import com.elvishew.xlog.XLog +import eu.chainfire.librootjava.RootJava +import eu.chainfire.libsuperuser.Shell +import moe.yuuta.workmode.BuildConfig +import moe.yuuta.workmode.suspend.data.ListMode +import moe.yuuta.workmode.suspend.data.Status +import moe.yuuta.workmode.utils.ByteArraySerializer + +/** + * The high-level API accessor, as known as the launcher (starter) of the accessor, wraps + * the necessary start steps to launch it and deserialize the result. + */ +open class AccessorStarter(private val mContext: Context, private val mLogPath: String) { + private val logger: Logger = XLog.tag("AccessorStarter").build() + + companion object { + const val ACTION_UPDATE_UI_STATE = "moe.yuuta.workmode.access.ACTION_UPDATE_UI_STATE" + const val ACTION_UPDATE_UI_PROGRESS = "moe.yuuta.workmode.access.ACTION_UPDATE_UI_PROGRESS" + const val EXTRA_SHOW_PROGRESS = "moe.yuuta.workmode.access.EXTRA_SHOW_PROGRESS" + } + + private fun launchRootProcess(root: Boolean, vararg args: String): MutableList { + val command = RootJava.getLaunchScript(mContext, + WorkModeAccessor::class.java, + null, + null, + args, + BuildConfig.APPLICATION_ID + ":accessor") + + return if (root) { + Shell.SU.run(command) + } else { + Shell.SH.run(command) + } + } + + fun getSuspendedPackageAppExtras(packageName: String, root: Boolean): Bundle? { + val argumentParcel: Parcel = Parcel.obtain() + try { + argumentParcel.writeString(mLogPath) + argumentParcel.writeString(WorkModeAccessor.ACTION_GET_APP_EXTRAS) + argumentParcel.writeString(packageName) + val marshalledResult = launchRootProcess(root, + ByteArraySerializer.serialize(argumentParcel.marshall()))[0] + val result = deserialize(ByteArraySerializer.deserialize(marshalledResult)) + val bundle = result.readBundle() + result.recycle() + return bundle + } finally { + argumentParcel.recycle() + } + } + + fun getSuspendedPackageLauncherExtras(packageName: String, root: Boolean): Bundle? { + val argumentParcel: Parcel = Parcel.obtain() + try { + argumentParcel.writeString(mLogPath) + argumentParcel.writeString(WorkModeAccessor.ACTION_GET_LAUNCHER_EXTRAS) + argumentParcel.writeString(packageName) + val marshalledResult = launchRootProcess(root, + ByteArraySerializer.serialize(argumentParcel.marshall()))[0] + val result = deserialize(ByteArraySerializer.deserialize(marshalledResult)) + val bundle = result.readBundle() + result.recycle() + return bundle + } finally { + argumentParcel.recycle() + } + } + + fun isPackageSuspended(packageNames: Array, root: Boolean): Boolean { + val argumentParcel: Parcel = Parcel.obtain() + try { + argumentParcel.writeString(mLogPath) + argumentParcel.writeString(WorkModeAccessor.ACTION_IS_SUSPENDED) + argumentParcel.writeStringArray(packageNames) + val marshalledResult = launchRootProcess(root, + WorkModeAccessor.ACTION_IS_SUSPENDED, + ByteArraySerializer.serialize(argumentParcel.marshall()))[0] + + val result = deserialize(ByteArraySerializer.deserialize(marshalledResult)) + val isSuspended = result.readByte() == 1.toByte() + result.recycle() + return isSuspended + } finally { + argumentParcel.recycle() + } + } + + fun dump(packageName: String, root: Boolean): DumpResult { + val argumentParcel: Parcel = Parcel.obtain() + try { + argumentParcel.writeString(mLogPath) + argumentParcel.writeString(WorkModeAccessor.ACTION_DUMP) + argumentParcel.writeString(packageName) + val marshalledResult = launchRootProcess(root, + ByteArraySerializer.serialize(argumentParcel.marshall()))[0] + val result = deserialize(ByteArraySerializer.deserialize(marshalledResult)) + val data = DumpResult( + result.readByte() == 1.toByte(), + result.readBundle(), + result.readBundle() + ) + result.recycle() + return data + } finally { + argumentParcel.recycle() + } + } + + fun setPackagesSuspended(packageNames: Array, suspended: Boolean, + appExtras: PersistableBundle, launcherExtras: PersistableBundle, + dialogMessage: String, root: Boolean): Array { + val argumentParcel: Parcel = Parcel.obtain() + try { + argumentParcel.writeString(mLogPath) + argumentParcel.writeString(WorkModeAccessor.ACTION_SET_SUSPENDED) + argumentParcel.writeStringArray(packageNames) + argumentParcel.writeByte(if (suspended) 1 else 0) + argumentParcel.writeBundle(Bundle(appExtras)) + argumentParcel.writeBundle(Bundle(launcherExtras)) + argumentParcel.writeString(dialogMessage) + val marshalledResult = launchRootProcess(root, + ByteArraySerializer.serialize(argumentParcel.marshall()))[0] + val result = deserialize(ByteArraySerializer.deserialize(marshalledResult)) + val rs = result.createStringArray() ?: arrayOf() + result.recycle() + return rs + } finally { + argumentParcel.recycle() + } + } + + @Throws(Throwable::class) + private fun deserialize(byteArray: ByteArray): Parcel { + val result = Parcel.obtain() + result.unmarshall(byteArray, 0, byteArray.size) + // Thanks to https://github.com/jiaminghan/droidplanner-master/blob/743b5436df6311cbbbfdecd21f796e2b948cbac7/Android/src/com/o3dr/services/android/lib/util/ParcelableUtils.java#L35 + result.setDataPosition(0) + when (result.readByte()) { + 1.toByte() -> { + } + 0.toByte() -> { + val throwable = result.readSerializable() as Throwable? + if (throwable != null) { + throw throwable + } + throw RuntimeException("Unsuccessful result with unknown stacktrace") + } + } + return result + } + + fun getPackagesSuspendedByWorkMode(root: Boolean): List { + val argumentParcel: Parcel = Parcel.obtain() + try { + argumentParcel.writeString(mLogPath) + argumentParcel.writeString(WorkModeAccessor.ACTION_GET_ALL_PACKAGES_SUSPENDED_BY_WORK_MODE) + val marshalledResult = launchRootProcess(root, + ByteArraySerializer.serialize(argumentParcel.marshall()))[0] + val result = deserialize(ByteArraySerializer.deserialize(marshalledResult)) + val data = result.createStringArrayList() + result.recycle() + return data ?: listOf() + } finally { + argumentParcel.recycle() + } + } + + fun apply(suspendList: Array, listMode: ListMode, status: Status, root: Boolean) { + val argumentParcel: Parcel = Parcel.obtain() + try { + argumentParcel.writeString(mLogPath) + argumentParcel.writeString(WorkModeAccessor.ACTION_APPLY) + argumentParcel.writeStringArray(suspendList) + argumentParcel.writeInt(when (listMode) { + ListMode.BLACKLIST -> 1 + ListMode.WHITELIST -> 2 + }) + argumentParcel.writeInt(when (status) { + Status.ON -> 1 + Status.OFF -> 2 + }) + val marshalledResult = launchRootProcess(root, + ByteArraySerializer.serialize(argumentParcel.marshall()))[0] + val result = deserialize(ByteArraySerializer.deserialize(marshalledResult)) + result.recycle() + } finally { + argumentParcel.recycle() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/moe/yuuta/workmode/access/ApplicationAccessorStarter.kt b/app/src/main/java/moe/yuuta/workmode/access/ApplicationAccessorStarter.kt new file mode 100644 index 0000000..8715989 --- /dev/null +++ b/app/src/main/java/moe/yuuta/workmode/access/ApplicationAccessorStarter.kt @@ -0,0 +1,9 @@ +package moe.yuuta.workmode.access + +import android.content.Context +import moe.yuuta.workmode.Setup + +/** + * A flavor of AccessorStarter which can be used in standard Android Applications + */ +class ApplicationAccessorStarter(private val mContext: Context) : AccessorStarter(mContext, Setup.getLogsPath(mContext).absolutePath) \ No newline at end of file diff --git a/app/src/main/java/moe/yuuta/workmode/access/DumpResult.kt b/app/src/main/java/moe/yuuta/workmode/access/DumpResult.kt new file mode 100644 index 0000000..d4a9d79 --- /dev/null +++ b/app/src/main/java/moe/yuuta/workmode/access/DumpResult.kt @@ -0,0 +1,7 @@ +package moe.yuuta.workmode.access + +import android.os.Bundle + +data class DumpResult(val isSuspended: Boolean, + val appExtras: Bundle?, + val launcherExtras: Bundle?) \ No newline at end of file diff --git a/app/src/main/java/moe/yuuta/workmode/access/ShellAccessorStarter.kt b/app/src/main/java/moe/yuuta/workmode/access/ShellAccessorStarter.kt new file mode 100644 index 0000000..05c4983 --- /dev/null +++ b/app/src/main/java/moe/yuuta/workmode/access/ShellAccessorStarter.kt @@ -0,0 +1,9 @@ +package moe.yuuta.workmode.access + +import eu.chainfire.librootjava.RootJava +import moe.yuuta.workmode.BuildConfig + +/** + * A flavor of AccessorStarter which can be used in shell programs + */ +class ShellAccessorStarter(private val mLogPath: String) : AccessorStarter(RootJava.getPackageContext(BuildConfig.APPLICATION_ID), mLogPath) \ No newline at end of file diff --git a/app/src/main/java/moe/yuuta/workmode/access/WorkModeAccessor.kt b/app/src/main/java/moe/yuuta/workmode/access/WorkModeAccessor.kt new file mode 100644 index 0000000..d8f17cd --- /dev/null +++ b/app/src/main/java/moe/yuuta/workmode/access/WorkModeAccessor.kt @@ -0,0 +1,317 @@ +package moe.yuuta.workmode.access + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.os.Parcel +import android.os.PersistableBundle +import android.service.quicksettings.TileService +import com.elvishew.xlog.Logger +import com.elvishew.xlog.XLog +import eu.chainfire.librootjava.RootJava +import moe.yuuta.workmode.BuildConfig +import moe.yuuta.workmode.R +import moe.yuuta.workmode.Setup +import moe.yuuta.workmode.suspend.SuspendTile +import moe.yuuta.workmode.suspend.SuspendedApp +import moe.yuuta.workmode.suspend.data.ListMode +import moe.yuuta.workmode.suspend.data.Status +import moe.yuuta.workmode.utils.BundleUtils +import moe.yuuta.workmode.utils.ByteArraySerializer +import moe.yuuta.workmode.utils.Utils +import java.util.stream.Collectors + +class WorkModeAccessor { + companion object { + const val ACTION_GET_APP_EXTRAS = "get_app_extras" + const val ACTION_IS_SUSPENDED = "is_suspended" + const val ACTION_GET_LAUNCHER_EXTRAS = "get_launcher_extras" + const val ACTION_SET_SUSPENDED = "set_suspended" + const val ACTION_DUMP = "dump" + const val ACTION_GET_ALL_PACKAGES_SUSPENDED_BY_WORK_MODE = "get_all_packages_suspended_by_work_mode" + const val ACTION_APPLY = "apply" + + @JvmStatic + fun main(vararg args: String) { + RootJava.restoreOriginalLdLibraryPath() + WorkModeAccessor().go(args) + } + } + + private lateinit var logger: Logger + private lateinit var mContext: Context + private lateinit var pmAccess: AccessLayer + private lateinit var mLogPath: String + + private fun go(args: Array) { + mContext = RootJava.getPackageContext(BuildConfig.APPLICATION_ID) + pmAccess = AccessLayer(mContext) + mContext.sendBroadcast(Intent(AccessorStarter.ACTION_UPDATE_UI_PROGRESS) + .putExtra(AccessorStarter.EXTRA_SHOW_PROGRESS, true)) + var parcel = Parcel.obtain() + val argsByteArray = ByteArraySerializer.deserialize(args[0]) + val argsParcel = Parcel.obtain() + argsParcel.unmarshall(argsByteArray, 0, argsByteArray.size) + argsParcel.setDataPosition(0) + mLogPath = argsParcel.readString() ?: "/data/adb" + Setup.initLogs(mLogPath) + logger = XLog.tag("Accessor").build() + try { + // General successful flag: 1 = success; 0 = unsuccessful + parcel.writeByte(1) + runGo(argsParcel, parcel) + } catch (e: Throwable) { + logger.e("Unexpected exception caused in accessor", e) + parcel.recycle() + // Re-mark it as unsuccessful + parcel = Parcel.obtain() + parcel.writeByte(0) + parcel.writeSerializable(e) + } + try { + TileService.requestListeningState(mContext, ComponentName(mContext, SuspendTile::class.java)) + mContext.sendBroadcast(Intent(AccessorStarter.ACTION_UPDATE_UI_STATE) + .setPackage(BuildConfig.APPLICATION_ID)) + } catch (e: Throwable) { + logger.e("Unable to refresh tile", e) + } + System.out.println(ByteArraySerializer.serialize(parcel.marshall())) + parcel.recycle() + mContext.sendBroadcast(Intent(AccessorStarter.ACTION_UPDATE_UI_PROGRESS) + .putExtra(AccessorStarter.EXTRA_SHOW_PROGRESS, false)) + System.exit(0) + } + + private fun runGo(argsParcel: Parcel, parcel: Parcel) { + when(argsParcel.readString()) { + ACTION_GET_APP_EXTRAS -> { + val bundle = pmAccess.getSuspendedPackageAppExtras(argsParcel.readString() ?: "android") + parcel.writeBundle(if (bundle != null) Bundle(bundle) else Bundle.EMPTY) + } + ACTION_IS_SUSPENDED -> { + val packageNames = argsParcel.createStringArray() ?: arrayOf("android") + var allSuspended = true + for (packageName in packageNames) { + if (!pmAccess.isPackageSuspended(packageName)) allSuspended = false + } + parcel.writeByte(if (allSuspended) 1 else 0) + } + ACTION_GET_LAUNCHER_EXTRAS -> { + parcel.writeBundle(pmAccess.getSuspendedPackageLauncherExtras(argsParcel.readString() ?: "android") ?: Bundle.EMPTY) + } + ACTION_SET_SUSPENDED -> { + val packageNames = argsParcel.createStringArray() ?: arrayOf("android") + val suspended = argsParcel.readByte() == 1.toByte() + logger.d("Running suspend: $suspended on ${packageNames.size} packages.") + val appExtras = argsParcel.readBundle() + val launcherExtras = argsParcel.readBundle() + val dialogMessage = argsParcel.readString() ?: "WorkMode" + argsParcel.recycle() + parcel.writeStringArray(suspend(packageNames, suspended, appExtras, launcherExtras, dialogMessage)) + } + ACTION_DUMP -> { + val pkg = argsParcel.readString() ?: "android" + parcel.writeByte(if (pmAccess.isPackageSuspended(pkg)) 1 else 0) + parcel.writeBundle(ShellAccessorStarter(mLogPath).getSuspendedPackageAppExtras(pkg, false)) + parcel.writeBundle(pmAccess.getSuspendedPackageLauncherExtras(pkg) ?: Bundle.EMPTY) + } + ACTION_GET_ALL_PACKAGES_SUSPENDED_BY_WORK_MODE -> { + parcel.writeStringList(getPackagesSuspendedByWorkMode()) + } + ACTION_APPLY -> { + apply(argsParcel) + } + } + } + + private fun suspend(packageNames: Array, suspended: Boolean, + appExtras: Bundle, launcherExtras: Bundle, + dialogMessage: String): Array = + pmAccess.setPackagesSuspended( + packageNames, + suspended, + BundleUtils.toPersistableBundle(appExtras), + BundleUtils.toPersistableBundle(launcherExtras), + dialogMessage + ) + + private fun getPackagesSuspendedByWorkMode(): List = + mContext.packageManager.getInstalledApplications(0) + .stream() + .filter(Utils.buildGeneralApplicationInfoFilter(mContext)) + .filter { + return@filter pmAccess.isPackageSuspended(it.packageName) && + SuspendedApp.deserializeBundle(pmAccess.getSuspendedPackageLauncherExtras(it.packageName)).isSuspendedByWorkMode + } + .map { + return@map it.packageName + } + .collect(Collectors.toList()) + + private fun apply(args: Parcel) { + // Compare system's list and ours. + // Blacklist: + // System suspended -> { + // in our list -> ON - don't care; OFF - unsuspend + // not in our list -> unsuspend + // } + // System not suspended -> { + // in our list -> ON - suspend; OFF - don't care + // not in our list -> don't care + // } + // Whitelist: + // System suspended -> { + // in our whitelist -> unsuspend + // not in our whitelist -> ON - don't care; OFF - unsuspend + // } + // System not suspended -> { + // in our whitelist -> don't care + // not in our whitelist -> ON - suspend; OFF - don't care + // } + + // This is the plan for Off->On or On->On situations. If we are heading + // Off, we just ignore all tasks which is going to suspend an app. Because + // we need to restore. + + // We use these two lists to determine whatever an app is suspended + // It it is suspended but not appears in systemSuspendedList, we know that + // it is suspended by other apps, like D**ital Wellbeing, we can just override it. + val systemSuspendedList = getPackagesSuspendedByWorkMode() + val systemAllAppList = mContext.packageManager.getInstalledApplications(0) + .stream() + .filter(Utils.buildGeneralApplicationInfoFilter(mContext)) + .map { return@map it.packageName } + .collect(Collectors.toList()) + val ourList = args.createStringArrayList() + val listMode = when (args.readInt()) { + 1 -> ListMode.BLACKLIST + 2 -> ListMode.WHITELIST + else -> throw IllegalArgumentException("Unexpected list mode") + } + val status = when (args.readInt()) { + 1 -> Status.ON + 2 -> Status.OFF + else -> throw IllegalArgumentException("Unexpected status") + } + + val tasks = systemAllAppList.stream() + // Filter "don't care" situations, do not map them here. + .filter { + val systemSuspended = systemSuspendedList.contains(it) + val inOurList = ourList.contains(it) + when (listMode) { + ListMode.BLACKLIST -> { + if (systemSuspended) { + if (inOurList) { + if (status == Status.ON) { + return@filter false + } else { + return@filter true + } + } else { + if (status == Status.ON) { + return@filter true + } else { + return@filter false + } + } + } else { + if (status == Status.ON) { + return@filter inOurList + } else { + return@filter false + } + } + } + ListMode.WHITELIST -> { + if (systemSuspended) { + if (inOurList) { + return@filter true + } else { + if (status == Status.ON) { + return@filter false + } else { + return@filter true + } + } + } else { + if (status == Status.ON) { + return@filter !inOurList + } else { + return@filter false + } + } + } + } + } + // Now, map them and determine that whatever a package should be suspended or un-suspended. + .map { + val systemSuspended = systemSuspendedList.contains(it) + when (listMode) { + ListMode.BLACKLIST -> { + if (systemSuspended) { + // It must be off (if in our list), + // or need to un-suspend (whatever on or off) + return@map SuspendTask(it, false) + } else { + // It must be on + return@map SuspendTask(it, true) + } + } + ListMode.WHITELIST -> { + if (systemSuspended) { + // It must be un-suspended (if in our list) + // (or off) + return@map SuspendTask(it, false) + } else { + // It must be on + return@map SuspendTask(it, true) + } + } + } + } + // Collect them, we will execute later. + .collect(Collectors.toList()) + // Suspend first + if (status == Status.ON) { + val suspendList = tasks.stream() + .filter { + return@filter it.suspend + } + .map { + return@map it.packageName + } + .collect(Collectors.toList()) + if (suspendList.size > 0) { + suspend(suspendList.toTypedArray(), + true) + } + } + // Then unsuspand + val unsuspendList = tasks.stream() + .filter { + return@filter !it.suspend + } + .map { + return@map it.packageName + } + .collect(Collectors.toList()) + if (unsuspendList.size > 0) { + suspend(unsuspendList.toTypedArray(), + false) + } + } + + private fun suspend(packageNames: Array, suspended: Boolean): Array = + pmAccess.setPackagesSuspended(packageNames, + suspended, + PersistableBundle(), + SuspendedApp.getDefault().serializeBundle(), // We use LauncherExtras because they are easy to read + mContext.getString(R.string.suspended_message)) +} + +private data class SuspendTask( + val packageName: String, + val suspend: Boolean +) \ No newline at end of file diff --git a/app/src/main/java/moe/yuuta/workmode/async/Async.kt b/app/src/main/java/moe/yuuta/workmode/async/Async.kt new file mode 100644 index 0000000..41542cd --- /dev/null +++ b/app/src/main/java/moe/yuuta/workmode/async/Async.kt @@ -0,0 +1,47 @@ +package moe.yuuta.workmode.async + +import android.annotation.SuppressLint +import android.os.AsyncTask + +object Async { + fun beginTask(runnable: Runnable, callback: Callback): Stoppable { + val task = @SuppressLint("StaticFieldLeak") + object : AsyncTask>() { + override fun onPostExecute(result: TaskResult) { + callback.onStop(result.successful, result.result, result.e) + } + + override fun doInBackground(vararg params: Void?): TaskResult { + try { + return TaskResult(true, runnable.run(), null) + } catch (e: Throwable) { + return TaskResult(false, null, e) + } + } + + override fun onPreExecute() { + callback.onStart() + } + } + val stoppable = object : Stoppable { + override fun stop() { + if (!isStopped()) task.cancel(true) + } + + override fun isStopped(): Boolean = task.isCancelled + } + task.execute() + return stoppable + } +} + +@FunctionalInterface +interface Runnable { + fun run(): T? +} + +data class TaskResult( + val successful: Boolean, + val result: T?, + val e: Throwable? +) \ No newline at end of file diff --git a/app/src/main/java/moe/yuuta/workmode/async/Callback.kt b/app/src/main/java/moe/yuuta/workmode/async/Callback.kt new file mode 100644 index 0000000..bac767b --- /dev/null +++ b/app/src/main/java/moe/yuuta/workmode/async/Callback.kt @@ -0,0 +1,6 @@ +package moe.yuuta.workmode.async + +interface Callback { + fun onStart() {} + fun onStop(success: Boolean, result: T?, e: Throwable?) {} +} \ No newline at end of file diff --git a/app/src/main/java/moe/yuuta/workmode/async/Stoppable.kt b/app/src/main/java/moe/yuuta/workmode/async/Stoppable.kt new file mode 100644 index 0000000..36e0335 --- /dev/null +++ b/app/src/main/java/moe/yuuta/workmode/async/Stoppable.kt @@ -0,0 +1,6 @@ +package moe.yuuta.workmode.async + +interface Stoppable { + fun stop() + fun isStopped(): Boolean +} \ No newline at end of file diff --git a/app/src/main/java/moe/yuuta/workmode/async/StoppableGroup.kt b/app/src/main/java/moe/yuuta/workmode/async/StoppableGroup.kt new file mode 100644 index 0000000..ef651fe --- /dev/null +++ b/app/src/main/java/moe/yuuta/workmode/async/StoppableGroup.kt @@ -0,0 +1,28 @@ +package moe.yuuta.workmode.async + +class StoppableGroup : Stoppable { + private val innerList: MutableList = mutableListOf() + + fun add(stoppable: Stoppable): StoppableGroup { + innerList.add(stoppable) + return this + } + + fun remove(stoppable: Stoppable): StoppableGroup { + innerList.remove(stoppable) + return this + } + + override fun stop() { + innerList.stream().forEach { + it.stop() + } + } + + override fun isStopped(): Boolean { + for (stoppable in innerList) { + if (!stoppable.isStopped()) return false + } + return true + } +} \ No newline at end of file diff --git a/app/src/main/java/moe/yuuta/workmode/suspend/AsyncSuspender.kt b/app/src/main/java/moe/yuuta/workmode/suspend/AsyncSuspender.kt new file mode 100644 index 0000000..8cb4947 --- /dev/null +++ b/app/src/main/java/moe/yuuta/workmode/suspend/AsyncSuspender.kt @@ -0,0 +1,27 @@ +package moe.yuuta.workmode.suspend + +import android.content.Context +import moe.yuuta.workmode.async.Async +import moe.yuuta.workmode.async.Callback +import moe.yuuta.workmode.async.Runnable +import moe.yuuta.workmode.async.Stoppable + +/** + * An async suspender which wraps suspend tasks and run them in the background + */ +class AsyncSuspender(private val mContext: Context) { + fun suspend(packageNames: Array, suspended: Boolean, callback: Callback>): Stoppable = + Async.beginTask(object : Runnable> { + override fun run(): Array = Suspender(mContext).suspend(packageNames, suspended) + }, callback) + + fun isSuspended(packageNames: Array, callback: Callback): Stoppable = + Async.beginTask(object : Runnable { + override fun run(): Boolean = Suspender(mContext).isSuspended(packageNames) + }, callback) + + fun applyFromSettings(callback: Callback): Stoppable = + Async.beginTask(object : Runnable { + override fun run(): Unit = Suspender(mContext).applyFromSettings() + }, callback) +} \ No newline at end of file diff --git a/app/src/main/java/moe/yuuta/workmode/suspend/SuspendTile.kt b/app/src/main/java/moe/yuuta/workmode/suspend/SuspendTile.kt new file mode 100644 index 0000000..f3fbc70 --- /dev/null +++ b/app/src/main/java/moe/yuuta/workmode/suspend/SuspendTile.kt @@ -0,0 +1,30 @@ +package moe.yuuta.workmode.suspend + +import android.content.Intent +import android.service.quicksettings.Tile +import android.service.quicksettings.TileService +import moe.yuuta.workmode.suspend.data.Status +import moe.yuuta.workmode.suspend.data.SuspendedStorage + +class SuspendTile : TileService() { + override fun onClick() { + val storage = SuspendedStorage(this) + storage.setStatus( + when (storage.getStatus()) { + Status.ON -> Status.OFF + Status.OFF -> Status.ON + } + ) + sendBroadcast(Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS)) + Suspender(this).applyFromSettings() + } + + override fun onStartListening() { + val tile = qsTile + tile.state = when (SuspendedStorage(this@SuspendTile).getStatus()) { + Status.ON -> Tile.STATE_ACTIVE + Status.OFF -> Tile.STATE_INACTIVE + } + tile.updateTile() + } +} \ No newline at end of file diff --git a/app/src/main/java/moe/yuuta/workmode/suspend/SuspendedApp.kt b/app/src/main/java/moe/yuuta/workmode/suspend/SuspendedApp.kt new file mode 100644 index 0000000..9c6ddf7 --- /dev/null +++ b/app/src/main/java/moe/yuuta/workmode/suspend/SuspendedApp.kt @@ -0,0 +1,38 @@ +package moe.yuuta.workmode.suspend + +import android.os.Bundle +import android.os.PersistableBundle +import moe.yuuta.workmode.BuildConfig + +/** + * The data class of a suspended app. This is ONLY used in this app, which + * can be understood as "a bridge between the extras stored in the system and + * information which is used in this app" + */ +data class SuspendedApp( + val isSuspendedByWorkMode: Boolean, // The flag which is used to determine whatever is suspended by WorkMode + val versionCodeSuspended: Int // The version code of this app when suspended the target +) { + companion object { + // These values are stored by the system, should not be usually changed for migrating + const val EXTRA_IS_SUSPENDED_BY_WORK_MODE = "moe.yuuta.workmode.EXTRA_IS_SUSPENDED_BY_WORK_MODE" + const val EXTRA_VERSION_CODE = "moe.yuuta.workmode.EXTRA_VERSION_CODE" + + fun deserializeBundle(launcherExtras: Bundle?): SuspendedApp { + if (launcherExtras == null) return SuspendedApp(false, -1) + return SuspendedApp( + launcherExtras.getBoolean(EXTRA_IS_SUSPENDED_BY_WORK_MODE, false), + launcherExtras.getInt(EXTRA_VERSION_CODE, -1) + ) + } + + fun getDefault(): SuspendedApp = SuspendedApp(true, BuildConfig.VERSION_CODE) + } + + fun serializeBundle(): PersistableBundle { + val bundle = PersistableBundle() + bundle.putBoolean(EXTRA_IS_SUSPENDED_BY_WORK_MODE, isSuspendedByWorkMode) + bundle.putInt(EXTRA_VERSION_CODE, versionCodeSuspended) + return bundle + } +} \ No newline at end of file diff --git a/app/src/main/java/moe/yuuta/workmode/suspend/Suspender.kt b/app/src/main/java/moe/yuuta/workmode/suspend/Suspender.kt new file mode 100644 index 0000000..49924bb --- /dev/null +++ b/app/src/main/java/moe/yuuta/workmode/suspend/Suspender.kt @@ -0,0 +1,40 @@ +package moe.yuuta.workmode.suspend + +import android.content.ComponentName +import android.content.Context +import android.os.PersistableBundle +import android.service.quicksettings.TileService +import moe.yuuta.workmode.R +import moe.yuuta.workmode.access.ApplicationAccessorStarter +import moe.yuuta.workmode.suspend.data.SuspendedStorage + +/** + * The highest-level suspender to wrap all information needed to suspend or vice versa. This + * should be called from UI components directly + * Chain: UI -> Suspender -> AccessorStarter -> WorkModeAccessor -> AccessLayer -> Framework + */ +class Suspender(private val mContext: Context) { + fun suspend(packageNames: Array, suspended: Boolean): Array = + ApplicationAccessorStarter(mContext).setPackagesSuspended(packageNames, + suspended, + PersistableBundle(), + SuspendedApp.getDefault().serializeBundle(), // We use LauncherExtras because they are easy to read + mContext.getString(R.string.suspended_message), + true) + + fun isSuspended(packageNames: Array): Boolean = + ApplicationAccessorStarter(mContext).isPackageSuspended(packageNames, true) + + fun getPackagesSuspendedByWorkMode(): List = + ApplicationAccessorStarter(mContext).getPackagesSuspendedByWorkMode(true) + + fun applyFromSettings() { + val storage = SuspendedStorage(mContext) + storage.cleanList(mContext) + val status = storage.getStatus() + val listMode = storage.getListMode() + val list = storage.getList() + ApplicationAccessorStarter(mContext).apply(list.toTypedArray(), listMode, status, true) + TileService.requestListeningState(mContext, ComponentName(mContext, SuspendTile::class.java)) + } +} \ No newline at end of file diff --git a/app/src/main/java/moe/yuuta/workmode/suspend/data/ListMode.kt b/app/src/main/java/moe/yuuta/workmode/suspend/data/ListMode.kt new file mode 100644 index 0000000..2ba0cc1 --- /dev/null +++ b/app/src/main/java/moe/yuuta/workmode/suspend/data/ListMode.kt @@ -0,0 +1,6 @@ +package moe.yuuta.workmode.suspend.data + +enum class ListMode { + BLACKLIST, + WHITELIST +} \ No newline at end of file diff --git a/app/src/main/java/moe/yuuta/workmode/suspend/data/Status.kt b/app/src/main/java/moe/yuuta/workmode/suspend/data/Status.kt new file mode 100644 index 0000000..aa57564 --- /dev/null +++ b/app/src/main/java/moe/yuuta/workmode/suspend/data/Status.kt @@ -0,0 +1,6 @@ +package moe.yuuta.workmode.suspend.data + +enum class Status { + OFF, + ON +} \ No newline at end of file diff --git a/app/src/main/java/moe/yuuta/workmode/suspend/data/SuspendedStorage.kt b/app/src/main/java/moe/yuuta/workmode/suspend/data/SuspendedStorage.kt new file mode 100644 index 0000000..c02af49 --- /dev/null +++ b/app/src/main/java/moe/yuuta/workmode/suspend/data/SuspendedStorage.kt @@ -0,0 +1,68 @@ +package moe.yuuta.workmode.suspend.data + +import android.content.Context +import android.content.SharedPreferences +import com.elvishew.xlog.XLog +import moe.yuuta.workmode.utils.Utils +import java.util.stream.Collectors + +/** + * An independent storage of suspended status + */ +class SuspendedStorage(private val mContext: Context) { + private val logger = XLog.tag("Storage").build() + + private fun getStorage(): SharedPreferences = mContext.getSharedPreferences("suspended", Context.MODE_PRIVATE) + + fun getList(): List = (getStorage().getStringSet("list", setOf()) ?: listOf()).toList() + + fun setList(set: Set) { + logger.d("s() $set") + getStorage().edit().putStringSet("list", set).apply() + } + + fun setStatus(status: Status) { + getStorage().edit().putInt("status", + when (status) { + Status.OFF -> 0 + Status.ON -> 1 + }).apply() + } + + fun getStatus(): Status = + when (getStorage().getInt("status", 0)) { + 0 -> Status.OFF + 1 -> Status.ON + else -> Status.OFF + } + + fun setListMode(listMode: ListMode) { + getStorage().edit().putInt("list_mode", + when (listMode) { + ListMode.BLACKLIST -> 0 + ListMode.WHITELIST -> 1 + }).apply() + } + + fun getListMode(): ListMode = + when (getStorage().getInt("list_mode", 0)) { + 0 -> ListMode.BLACKLIST + 1 -> ListMode.WHITELIST + else -> ListMode.BLACKLIST + } + + fun cleanList(context: Context) { + val installed = mContext.packageManager.getInstalledApplications(0) + .stream() + .filter(Utils.buildGeneralApplicationInfoFilter(context)) + .map { + return@map it.packageName + } + .collect(Collectors.toList()) + setList(getList().stream() + .filter { + return@filter installed.contains(it) + } + .collect(Collectors.toSet())) + } +} \ No newline at end of file diff --git a/app/src/main/java/moe/yuuta/workmode/update/UpdateChecker.kt b/app/src/main/java/moe/yuuta/workmode/update/UpdateChecker.kt new file mode 100644 index 0000000..e88ab80 --- /dev/null +++ b/app/src/main/java/moe/yuuta/workmode/update/UpdateChecker.kt @@ -0,0 +1,70 @@ +package moe.yuuta.workmode.update + +import com.elvishew.xlog.XLog +import moe.yuuta.workmode.BuildConfig +import moe.yuuta.workmode.async.Runnable +import org.json.JSONObject +import java.io.BufferedReader +import java.io.InputStreamReader +import java.net.HttpURLConnection +import java.net.URL + + +class UpdateChecker : Runnable { + private val logger = XLog.tag("UpdateChecker").build() + override fun run(): Update { + logger.d("Start checking") + var httpURLConnection: HttpURLConnection? = null + try { + val url = URL("https://raw.githubusercontent.com/Trumeet/_priv/master/update/${BuildConfig.APPLICATION_ID}.json") + httpURLConnection = url.openConnection() as HttpURLConnection + httpURLConnection.requestMethod = "GET" + httpURLConnection.connect() + + val inputStream = httpURLConnection.inputStream + val buffer = StringBuffer() + + if (inputStream == null) { + throw IllegalStateException("Str is null.") + } + val reader = BufferedReader(InputStreamReader(inputStream)) + + var line: String? + while (true) { + line = reader.readLine() + if (line == null) break + buffer.append(line + "\n") + } + + if (buffer.isEmpty()) { + throw IllegalStateException("Buffer is empty") + } + + val response = buffer.toString() + + logger.i("Response: ${httpURLConnection.responseCode} (${httpURLConnection.responseMessage}), data: $response") + + val json = JSONObject(response) + return Update(json.getInt("version"), + json.getString("name"), + json.getString("alt_url"), + json.getBoolean("alt_url_enable"), + json.getBoolean("alt_url_force")) + } catch (e: Exception) { + logger.e("Unable to check", e) + throw e + } finally { + // this is done so that there are no open connections left when this task is going to complete + if (httpURLConnection != null) + httpURLConnection.disconnect() + } + } +} + +data class Update( + val version: Int, + val name: String, + val altUrl: String, + val altUrlEnabled: Boolean, + val altUrlForce: Boolean +) \ No newline at end of file diff --git a/app/src/main/java/moe/yuuta/workmode/utils/BundleUtils.java b/app/src/main/java/moe/yuuta/workmode/utils/BundleUtils.java new file mode 100644 index 0000000..28c96b0 --- /dev/null +++ b/app/src/main/java/moe/yuuta/workmode/utils/BundleUtils.java @@ -0,0 +1,89 @@ +package moe.yuuta.workmode.utils; + +import android.os.BaseBundle; +import android.os.Bundle; +import android.os.PersistableBundle; + +public class BundleUtils { + /** + * Creates a new {@link Bundle} based on the specified {@link PersistableBundle}. + */ + public static Bundle toBundle(PersistableBundle persistableBundle) { + if (persistableBundle == null) { + return null; + } + Bundle bundle = new Bundle(); + bundle.putAll(persistableBundle); + return bundle; + } + + /** + * Creates a new {@link PersistableBundle} from the specified {@link Bundle}. + * Will ignore all values that are not persistable, according + * to {@link #isPersistableBundleType(Object)}. + */ + public static PersistableBundle toPersistableBundle(Bundle bundle) { + if (bundle == null) { + return null; + } + PersistableBundle persistableBundle = new PersistableBundle(); + for (String key : bundle.keySet()) { + Object value = bundle.get(key); + if (isPersistableBundleType(value)) { + putIntoBundle(persistableBundle, key, value); + } + } + return persistableBundle; + } + + /** + * Checks if the specified object can be put into a {@link PersistableBundle}. + * + * @see PersistableBundle Implementation + */ + public static boolean isPersistableBundleType(Object value) { + return ((value instanceof PersistableBundle) || + (value instanceof Integer) || (value instanceof int[]) || + (value instanceof Long) || (value instanceof long[]) || + (value instanceof Double) || (value instanceof double[]) || + (value instanceof String) || (value instanceof String[]) || + (value instanceof Boolean) || (value instanceof boolean[]) + ); + } + + /** + * Attempts to insert the specified key value pair into the specified bundle. + * + * @throws IllegalArgumentException if the value type can not be put into the bundle. + */ + public static void putIntoBundle(BaseBundle baseBundle, String key, Object value) throws IllegalArgumentException { + if (value == null) { + throw new IllegalArgumentException("Unable to determine type of null values"); + } else if (value instanceof Integer) { + baseBundle.putInt(key, (int) value); + } else if (value instanceof int[]) { + baseBundle.putIntArray(key, (int[]) value); + } else if (value instanceof Long) { + baseBundle.putLong(key, (long) value); + } else if (value instanceof long[]) { + baseBundle.putLongArray(key, (long[]) value); + } else if (value instanceof Double) { + baseBundle.putDouble(key, (double) value); + } else if (value instanceof double[]) { + baseBundle.putDoubleArray(key, (double[]) value); + } else if (value instanceof String) { + baseBundle.putString(key, (String) value); + } else if (value instanceof String[]) { + baseBundle.putStringArray(key, (String[]) value); + } else if (value instanceof Boolean) { + baseBundle.putBoolean(key, (boolean) value); + } else if (value instanceof boolean[]) { + baseBundle.putBooleanArray(key, (boolean[]) value); + } else if (value instanceof PersistableBundle) { + baseBundle.putAll((PersistableBundle) value); + } else { + throw new IllegalArgumentException("Objects of type " + value.getClass().getSimpleName() + + " can not be put into a " + BaseBundle.class.getSimpleName()); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/moe/yuuta/workmode/utils/ByteArraySerializer.kt b/app/src/main/java/moe/yuuta/workmode/utils/ByteArraySerializer.kt new file mode 100644 index 0000000..8792e04 --- /dev/null +++ b/app/src/main/java/moe/yuuta/workmode/utils/ByteArraySerializer.kt @@ -0,0 +1,26 @@ +package moe.yuuta.workmode.utils + +object ByteArraySerializer { + fun serialize(array: ByteArray): String { + val builder = StringBuilder() + array.toList().stream() + .forEachOrdered { + builder.append(it) + builder.append(',') + } + var result = builder.toString() + result = result.substring(0, result.length - 1) + return result + } + + fun deserialize(value: String): ByteArray { + val list = value.split(',').toList() + var array = ByteArray(0) + + list.stream() + .forEachOrdered { + array = array.plus(it.toByte()) + } + return array + } +} \ No newline at end of file diff --git a/app/src/main/java/moe/yuuta/workmode/utils/Utils.kt b/app/src/main/java/moe/yuuta/workmode/utils/Utils.kt new file mode 100644 index 0000000..2ac68e9 --- /dev/null +++ b/app/src/main/java/moe/yuuta/workmode/utils/Utils.kt @@ -0,0 +1,127 @@ +package moe.yuuta.workmode.utils + +import android.content.Context +import android.content.Intent +import android.content.pm.ApplicationInfo +import android.os.Bundle +import android.os.Process +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.core.view.children +import com.google.android.material.tabs.TabLayout +import moe.yuuta.workmode.BuildConfig +import java.util.function.Predicate +import java.util.stream.Collectors + + +object Utils { + private val PROTECTED_UIDS = arrayOf(Process.SYSTEM_UID, + Process.myUid(), + Process.PHONE_UID, + 0, + 2000, + 1007, + 1010, + 1013, + 1019, + 1016, + 1017, + 1027, + 1002, + 1023, + 1032, + 1037, + 1041, + 1047, + 1053, + 1061, + 1067, + 1068, + 9999) + + private val PROTECTED_PACKAGES = arrayOf( + "android", + BuildConfig.APPLICATION_ID + ) + + private val PROTECTED_PACKAGES_WIDE_MATCH = arrayOf( + "com.android." + ) + + private val WHITELIST_PKGS = arrayOf( + "com.android.chrome" + ) + + fun buildGeneralApplicationInfoFilter(context: Context): Predicate { + val i = Intent(Intent.ACTION_MAIN) + i.addCategory(Intent.CATEGORY_HOME) + val launchers = context.packageManager.queryIntentActivities(i, 0) + .stream() + .map { + return@map it.resolvePackageName + } + .collect(Collectors.toList()) + return object : Predicate { + override fun test(it: ApplicationInfo): Boolean { + for (pkg in WHITELIST_PKGS) + if (pkg == it.packageName) { + return true + } + for (pkg in PROTECTED_PACKAGES) + if (pkg == it.packageName) { + return false + } + for (pkg in PROTECTED_PACKAGES_WIDE_MATCH) + if (it.packageName.startsWith(pkg)) { + return false + } + for (uid in PROTECTED_UIDS) + if (uid == it.uid) { + return false + } + if (launchers.contains(it.packageName)) { + return false + } + if (it.uid < Process.FIRST_APPLICATION_UID || it.uid > Process.LAST_APPLICATION_UID) { + return false + } + return context.packageManager.getLaunchIntentForPackage(it.packageName) != null + } + } + } + + fun dumpExtras(bundle: Bundle?): String { + val builder = StringBuilder() + if (bundle != null) { + for (key in bundle.keySet()) { + val value = bundle.get(key) + builder.append("value: ") + builder.append(value?.toString()) + builder.append(" key: ") + builder.append(key) + builder.append(" type: ") + builder.append(value.javaClass.name) + } + } + return builder.toString() + } + + fun setViewTreeEnable(viewGroup: ViewGroup, isEnabled: Boolean) { + for (child in viewGroup.children) { + if (child is ViewGroup) + setViewTreeEnable(child, isEnabled) + else + child.isEnabled = isEnabled + } + } + + /** + * Thanks to https://stackoverflow.com/questions/31702725/disable-tablayout + */ + fun makeTabLayoutDisable(tabLayout: TabLayout, enable: Boolean) { + val tabStrip = tabLayout.getChildAt(0) as LinearLayout + for (i in 0 until tabStrip.childCount) { + tabStrip.getChildAt(i).setOnTouchListener { v, event -> !enable } + } + } +} \ No newline at end of file diff --git a/app/src/main/res/color/switchbar_switch_thumb_tint.xml b/app/src/main/res/color/switchbar_switch_thumb_tint.xml new file mode 100644 index 0000000..d274d93 --- /dev/null +++ b/app/src/main/res/color/switchbar_switch_thumb_tint.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/switchbar_switch_track_tint.xml b/app/src/main/res/color/switchbar_switch_track_tint.xml new file mode 100644 index 0000000..f316d32 --- /dev/null +++ b/app/src/main/res/color/switchbar_switch_track_tint.xml @@ -0,0 +1,18 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..1f6bb29 --- /dev/null +++ b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_add_24dp.xml b/app/src/main/res/drawable/ic_add_24dp.xml new file mode 100644 index 0000000..e3979cd --- /dev/null +++ b/app/src/main/res/drawable/ic_add_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_check_24dp.xml b/app/src/main/res/drawable/ic_check_24dp.xml new file mode 100644 index 0000000..17aca2a --- /dev/null +++ b/app/src/main/res/drawable/ic_check_24dp.xml @@ -0,0 +1,5 @@ + + + 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..0d025f9 --- /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_work_24dp.xml b/app/src/main/res/drawable/ic_work_24dp.xml new file mode 100644 index 0000000..f211713 --- /dev/null +++ b/app/src/main/res/drawable/ic_work_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/layout/activity_application_picker.xml b/app/src/main/res/layout/activity_application_picker.xml new file mode 100644 index 0000000..2dfaac2 --- /dev/null +++ b/app/src/main/res/layout/activity_application_picker.xml @@ -0,0 +1,33 @@ + + + + + + + + \ No newline at end of file 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..23f0416 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_application.xml b/app/src/main/res/layout/item_application.xml new file mode 100644 index 0000000..7ab17dc --- /dev/null +++ b/app/src/main/res/layout/item_application.xml @@ -0,0 +1,31 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_application_select.xml b/app/src/main/res/layout/item_application_select.xml new file mode 100644 index 0000000..c5068d4 --- /dev/null +++ b/app/src/main/res/layout/item_application_select.xml @@ -0,0 +1,42 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/switch_bar.xml b/app/src/main/res/layout/switch_bar.xml new file mode 100644 index 0000000..b95c6ea --- /dev/null +++ b/app/src/main/res/layout/switch_bar.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_main.xml b/app/src/main/res/menu/menu_main.xml new file mode 100644 index 0000000..df1c7f3 --- /dev/null +++ b/app/src/main/res/menu/menu_main.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..eca70cf --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..eca70cf --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..898f3ed Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..dffca36 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..64ba76f Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..dae5e08 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..e5ed465 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..14ed0af Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..b0907ca Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..d8ae031 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..2c18de9 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..beed3cd Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml new file mode 100644 index 0000000..9e90cdd --- /dev/null +++ b/app/src/main/res/values/attrs.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..5a06de3 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,8 @@ + + + #008577 + #00574B + #D81B60 + #ff80868B + #ff80cbc4 + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml new file mode 100644 index 0000000..fead519 --- /dev/null +++ b/app/src/main/res/values/dimens.xml @@ -0,0 +1,8 @@ + + + 48dp + 72dp + 16dp + 16dp + 16dp + \ 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..a536b1e --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,27 @@ + + Work Mode + Work + This app is disabled because you want to enjoy your work. + + On + Off + Apps + Add + Blacklist + Whitelist + Add apps to be disabled when you are working + Add apps to be enabled only when you are working + Switch to blacklist + These apps will be disabled and others will be enabled when you are working + Switch to whitelist + These apps will be enabled and others will be disabled when you are working + Cannot get applications + Unable to apply settings + Feedback + Get logs + Check update + About + Work Mode feedback + %1$s is available + View + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..7af43d0 --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/xml/filepaths.xml b/app/src/main/res/xml/filepaths.xml new file mode 100644 index 0000000..513a653 --- /dev/null +++ b/app/src/main/res/xml/filepaths.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..1a9979d --- /dev/null +++ b/build.gradle @@ -0,0 +1,29 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + ext.kotlin_version = '1.3.21' + repositories { + google() + jcenter() + + } + dependencies { + classpath 'com.android.tools.build:gradle:3.5.0-alpha03' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + classpath 'com.google.android.gms:oss-licenses-plugin:0.9.4' + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +allprojects { + repositories { + google() + jcenter() + + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..23339e0 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,21 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx1536m +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Automatically convert third-party libraries to use AndroidX +android.enableJetifier=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..f6b961f Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..b0e751c --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Fri Feb 22 18:50:48 PST 2019 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-5.1.1-all.zip diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..cccdd3d --- /dev/null +++ b/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..e95643d --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..e7b4def --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +include ':app' -- cgit v1.2.3