diff --git a/core/java/com/android/internal/app/PlatLogoActivity.java b/core/java/com/android/internal/app/PlatLogoActivity.java
index 2a7eae6267951..986bbc8628ec1 100644
--- a/core/java/com/android/internal/app/PlatLogoActivity.java
+++ b/core/java/com/android/internal/app/PlatLogoActivity.java
@@ -55,6 +55,10 @@ import org.json.JSONObject;
public class PlatLogoActivity extends Activity {
private static final boolean WRITE_SETTINGS = true;
+ private static final String R_EGG_UNLOCK_SETTING = "egg_mode_r";
+
+ private static final int UNLOCK_TRIES = 3;
+
BigDialView mDialView;
@Override
@@ -77,8 +81,10 @@ public class PlatLogoActivity extends Activity {
mDialView = new BigDialView(this, null);
if (Settings.System.getLong(getContentResolver(),
- "egg_mode" /* Settings.System.EGG_MODE */, 0) == 0) {
- mDialView.setUnlockTries(3);
+ R_EGG_UNLOCK_SETTING, 0) == 0) {
+ mDialView.setUnlockTries(UNLOCK_TRIES);
+ } else {
+ mDialView.setUnlockTries(0);
}
final FrameLayout layout = new FrameLayout(this);
@@ -91,18 +97,16 @@ public class PlatLogoActivity extends Activity {
private void launchNextStage(boolean locked) {
final ContentResolver cr = getContentResolver();
- if (Settings.System.getLong(cr, "egg_mode" /* Settings.System.EGG_MODE */, 0) == 0) {
- // For posterity: the moment this user unlocked the easter egg
- try {
- if (WRITE_SETTINGS) {
- Settings.System.putLong(cr,
- "egg_mode", // Settings.System.EGG_MODE,
- locked ? 0 : System.currentTimeMillis());
- }
- } catch (RuntimeException e) {
- Log.e("com.android.internal.app.PlatLogoActivity", "Can't write settings", e);
+ try {
+ if (WRITE_SETTINGS) {
+ Settings.System.putLong(cr,
+ R_EGG_UNLOCK_SETTING,
+ locked ? 0 : System.currentTimeMillis());
}
+ } catch (RuntimeException e) {
+ Log.e("com.android.internal.app.PlatLogoActivity", "Can't write settings", e);
}
+
try {
startActivity(new Intent(Intent.ACTION_MAIN)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK
@@ -235,8 +239,8 @@ public class PlatLogoActivity extends Activity {
}
return true;
case MotionEvent.ACTION_UP:
- if (mWasLocked && !mDialDrawable.isLocked()) {
- launchNextStage(false);
+ if (mWasLocked != mDialDrawable.isLocked()) {
+ launchNextStage(mDialDrawable.isLocked());
}
return true;
}
@@ -404,6 +408,8 @@ public class PlatLogoActivity extends Activity {
if (isLocked() && oldUserLevel != STEPS - 1 && getUserLevel() == STEPS - 1) {
mUnlockTries--;
+ } else if (!isLocked() && getUserLevel() == 0) {
+ mUnlockTries = UNLOCK_TRIES;
}
if (!isLocked()) {
diff --git a/packages/EasterEgg/Android.bp b/packages/EasterEgg/Android.bp
index 43ed810b56744..b858ab01ffd96 100644
--- a/packages/EasterEgg/Android.bp
+++ b/packages/EasterEgg/Android.bp
@@ -23,11 +23,23 @@ android_app {
name: "EasterEgg",
+ platform_apis: true,
certificate: "platform",
- sdk_version: "current",
-
optimize: {
enabled: false,
- }
+ },
+
+ static_libs: [
+ "androidx.core_core",
+ "androidx.recyclerview_recyclerview",
+ "androidx.annotation_annotation",
+ "kotlinx-coroutines-android",
+ "kotlinx-coroutines-core",
+ //"kotlinx-coroutines-reactive",
+ ],
+
+ manifest: "AndroidManifest.xml",
+
+ kotlincflags: ["-Xjvm-default=enable"],
}
diff --git a/packages/EasterEgg/AndroidManifest.xml b/packages/EasterEgg/AndroidManifest.xml
index 7f76a45299635..57c459b6f0fd4 100644
--- a/packages/EasterEgg/AndroidManifest.xml
+++ b/packages/EasterEgg/AndroidManifest.xml
@@ -6,19 +6,24 @@
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/EasterEgg/build.gradle b/packages/EasterEgg/build.gradle
new file mode 100644
index 0000000000000..20b469898498b
--- /dev/null
+++ b/packages/EasterEgg/build.gradle
@@ -0,0 +1,82 @@
+buildscript {
+ ext.kotlin_version = '1.3.71'
+
+ repositories {
+ google()
+ jcenter()
+ }
+
+ dependencies {
+ classpath 'com.android.tools.build:gradle:4.0.0'
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
+ }
+}
+
+allprojects {
+ repositories {
+ google()
+ jcenter()
+ }
+}
+
+apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+apply plugin: 'kotlin-android-extensions'
+
+final String ANDROID_ROOT = "${rootDir}/../../../.."
+
+android {
+ compileSdkVersion COMPILE_SDK
+ buildToolsVersion BUILD_TOOLS_VERSION
+
+ defaultConfig {
+ applicationId "com.android.egg"
+ minSdkVersion 28
+ targetSdkVersion 30
+ versionCode 1
+ versionName "1.0"
+
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+
+ sourceSets {
+ main {
+ res.srcDirs = ['res']
+ java.srcDirs = ['src']
+ manifest.srcFile 'AndroidManifest.xml'
+ }
+ }
+
+ signingConfigs {
+ debug.storeFile file("${ANDROID_ROOT}/vendor/google/certs/devkeys/platform.keystore")
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+ }
+ }
+
+
+}
+
+dependencies {
+ implementation fileTree(dir: 'libs', include: ['*.jar'])
+ implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
+ implementation 'androidx.appcompat:appcompat:1.1.0'
+ implementation 'androidx.core:core-ktx:1.2.0'
+ implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.6'
+ implementation "androidx.recyclerview:recyclerview:${ANDROID_X_VERSION}"
+ implementation "androidx.dynamicanimation:dynamicanimation:${ANDROID_X_VERSION}"
+ testImplementation 'junit:junit:4.12'
+ androidTestImplementation 'androidx.test.ext:junit:1.1.1'
+ androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
+ androidTestImplementation "androidx.annotation:annotation:${ANDROID_X_VERSION}"
+}
+
diff --git a/packages/EasterEgg/gradle.properties b/packages/EasterEgg/gradle.properties
new file mode 100644
index 0000000000000..e8e6450e2943f
--- /dev/null
+++ b/packages/EasterEgg/gradle.properties
@@ -0,0 +1,23 @@
+# 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
+android.enableJetifier=true
+kotlin.code.style=official
+
+ANDROID_X_VERSION=1+
+COMPILE_SDK=android-30
+BUILD_TOOLS_VERSION=28.0.3
diff --git a/packages/EasterEgg/res/drawable/android_11_dial.xml b/packages/EasterEgg/res/drawable/android_11_dial.xml
new file mode 100644
index 0000000000000..73fd37f1bdd60
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/android_11_dial.xml
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/EasterEgg/res/drawable/back.xml b/packages/EasterEgg/res/drawable/back.xml
new file mode 100644
index 0000000000000..b55d65cdf76d8
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/back.xml
@@ -0,0 +1,22 @@
+
+
+
+
diff --git a/packages/EasterEgg/res/drawable/belly.xml b/packages/EasterEgg/res/drawable/belly.xml
new file mode 100644
index 0000000000000..8b0e9afac4632
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/belly.xml
@@ -0,0 +1,22 @@
+
+
+
+
diff --git a/packages/EasterEgg/res/drawable/body.xml b/packages/EasterEgg/res/drawable/body.xml
new file mode 100644
index 0000000000000..86087209eff5f
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/body.xml
@@ -0,0 +1,22 @@
+
+
+
+
diff --git a/packages/EasterEgg/res/drawable/bowtie.xml b/packages/EasterEgg/res/drawable/bowtie.xml
new file mode 100644
index 0000000000000..33fa9216712fb
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/bowtie.xml
@@ -0,0 +1,22 @@
+
+
+
+
diff --git a/packages/EasterEgg/res/drawable/cap.xml b/packages/EasterEgg/res/drawable/cap.xml
new file mode 100644
index 0000000000000..d8b4cc58a261f
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/cap.xml
@@ -0,0 +1,22 @@
+
+
+
+
diff --git a/packages/EasterEgg/res/drawable/collar.xml b/packages/EasterEgg/res/drawable/collar.xml
new file mode 100644
index 0000000000000..5e4d0fd4f8865
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/collar.xml
@@ -0,0 +1,22 @@
+
+
+
+
diff --git a/packages/EasterEgg/res/drawable/face_spot.xml b/packages/EasterEgg/res/drawable/face_spot.xml
new file mode 100644
index 0000000000000..a89fb4fdaadd5
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/face_spot.xml
@@ -0,0 +1,22 @@
+
+
+
+
diff --git a/packages/EasterEgg/res/drawable/food_bits.xml b/packages/EasterEgg/res/drawable/food_bits.xml
new file mode 100644
index 0000000000000..1b2bb6f369470
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/food_bits.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
diff --git a/packages/EasterEgg/res/drawable/food_chicken.xml b/packages/EasterEgg/res/drawable/food_chicken.xml
new file mode 100644
index 0000000000000..95b2fb55b7967
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/food_chicken.xml
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
diff --git a/packages/EasterEgg/res/drawable/food_cookie.xml b/packages/EasterEgg/res/drawable/food_cookie.xml
new file mode 100644
index 0000000000000..74dd134355e23
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/food_cookie.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/packages/EasterEgg/res/drawable/food_dish.xml b/packages/EasterEgg/res/drawable/food_dish.xml
new file mode 100644
index 0000000000000..3fff6a90fad2b
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/food_dish.xml
@@ -0,0 +1,24 @@
+
+
+
+
diff --git a/packages/EasterEgg/res/drawable/food_donut.xml b/packages/EasterEgg/res/drawable/food_donut.xml
new file mode 100644
index 0000000000000..eaf831ea560c0
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/food_donut.xml
@@ -0,0 +1,24 @@
+
+
+
+
diff --git a/packages/EasterEgg/res/drawable/food_sysuituna.xml b/packages/EasterEgg/res/drawable/food_sysuituna.xml
new file mode 100644
index 0000000000000..28cf4a2c76836
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/food_sysuituna.xml
@@ -0,0 +1,24 @@
+
+
+
+
diff --git a/packages/EasterEgg/res/drawable/foot1.xml b/packages/EasterEgg/res/drawable/foot1.xml
new file mode 100644
index 0000000000000..0d9085998a182
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/foot1.xml
@@ -0,0 +1,22 @@
+
+
+
+
diff --git a/packages/EasterEgg/res/drawable/foot2.xml b/packages/EasterEgg/res/drawable/foot2.xml
new file mode 100644
index 0000000000000..364ba0cd861c4
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/foot2.xml
@@ -0,0 +1,22 @@
+
+
+
+
diff --git a/packages/EasterEgg/res/drawable/foot3.xml b/packages/EasterEgg/res/drawable/foot3.xml
new file mode 100644
index 0000000000000..e3a512a2568d9
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/foot3.xml
@@ -0,0 +1,22 @@
+
+
+
+
diff --git a/packages/EasterEgg/res/drawable/foot4.xml b/packages/EasterEgg/res/drawable/foot4.xml
new file mode 100644
index 0000000000000..66b78fa266490
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/foot4.xml
@@ -0,0 +1,22 @@
+
+
+
+
diff --git a/packages/EasterEgg/res/drawable/head.xml b/packages/EasterEgg/res/drawable/head.xml
new file mode 100644
index 0000000000000..df600a8613cd6
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/head.xml
@@ -0,0 +1,22 @@
+
+
+
+
diff --git a/packages/EasterEgg/res/drawable/ic_bowl.xml b/packages/EasterEgg/res/drawable/ic_bowl.xml
new file mode 100644
index 0000000000000..d55565d92988d
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/ic_bowl.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
diff --git a/packages/EasterEgg/res/drawable/ic_close.xml b/packages/EasterEgg/res/drawable/ic_close.xml
new file mode 100644
index 0000000000000..60ea36b11fccd
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/ic_close.xml
@@ -0,0 +1,24 @@
+
+
+
+
diff --git a/packages/EasterEgg/res/drawable/ic_foodbowl_filled.xml b/packages/EasterEgg/res/drawable/ic_foodbowl_filled.xml
new file mode 100644
index 0000000000000..54961af68aef5
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/ic_foodbowl_filled.xml
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/EasterEgg/res/drawable/ic_fullcat_icon.xml b/packages/EasterEgg/res/drawable/ic_fullcat_icon.xml
new file mode 100644
index 0000000000000..5dca3d18f2d45
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/ic_fullcat_icon.xml
@@ -0,0 +1,108 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/EasterEgg/res/drawable/ic_share.xml b/packages/EasterEgg/res/drawable/ic_share.xml
new file mode 100644
index 0000000000000..8cebc7ed46dea
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/ic_share.xml
@@ -0,0 +1,24 @@
+
+
+
+
diff --git a/packages/EasterEgg/res/drawable/ic_toy_ball.xml b/packages/EasterEgg/res/drawable/ic_toy_ball.xml
new file mode 100644
index 0000000000000..411084b2a272d
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/ic_toy_ball.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
diff --git a/packages/EasterEgg/res/drawable/ic_toy_fish.xml b/packages/EasterEgg/res/drawable/ic_toy_fish.xml
new file mode 100644
index 0000000000000..bb01e9f32bfbf
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/ic_toy_fish.xml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
diff --git a/packages/EasterEgg/res/drawable/ic_toy_laser.xml b/packages/EasterEgg/res/drawable/ic_toy_laser.xml
new file mode 100644
index 0000000000000..8fe84ffbd38c4
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/ic_toy_laser.xml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/EasterEgg/res/drawable/ic_toy_mouse.xml b/packages/EasterEgg/res/drawable/ic_toy_mouse.xml
new file mode 100644
index 0000000000000..ba3dc33220836
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/ic_toy_mouse.xml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/EasterEgg/res/drawable/ic_water.xml b/packages/EasterEgg/res/drawable/ic_water.xml
new file mode 100644
index 0000000000000..7d94b24096361
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/ic_water.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
diff --git a/packages/EasterEgg/res/drawable/ic_water_filled.xml b/packages/EasterEgg/res/drawable/ic_water_filled.xml
new file mode 100644
index 0000000000000..eed171d05668a
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/ic_water_filled.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
diff --git a/packages/EasterEgg/res/drawable/ic_waterbowl_filled.xml b/packages/EasterEgg/res/drawable/ic_waterbowl_filled.xml
new file mode 100644
index 0000000000000..28b1fa8240605
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/ic_waterbowl_filled.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
diff --git a/packages/EasterEgg/res/drawable/icon.xml b/packages/EasterEgg/res/drawable/icon.xml
new file mode 100644
index 0000000000000..7f8d4fa8833ff
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/icon.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
diff --git a/packages/EasterEgg/res/drawable/icon_bg.xml b/packages/EasterEgg/res/drawable/icon_bg.xml
index 659f98be4f435..31b2a7f9a333d 100644
--- a/packages/EasterEgg/res/drawable/icon_bg.xml
+++ b/packages/EasterEgg/res/drawable/icon_bg.xml
@@ -1,8 +1,7 @@
-
+ android:color="#073042" />
+
diff --git a/packages/EasterEgg/res/drawable/left_ear.xml b/packages/EasterEgg/res/drawable/left_ear.xml
new file mode 100644
index 0000000000000..2b98736df039b
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/left_ear.xml
@@ -0,0 +1,22 @@
+
+
+
+
diff --git a/packages/EasterEgg/res/drawable/left_ear_inside.xml b/packages/EasterEgg/res/drawable/left_ear_inside.xml
new file mode 100644
index 0000000000000..1d947edc31e21
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/left_ear_inside.xml
@@ -0,0 +1,22 @@
+
+
+
+
diff --git a/packages/EasterEgg/res/drawable/left_eye.xml b/packages/EasterEgg/res/drawable/left_eye.xml
new file mode 100644
index 0000000000000..4dde1b661393c
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/left_eye.xml
@@ -0,0 +1,22 @@
+
+
+
+
diff --git a/packages/EasterEgg/res/drawable/leg1.xml b/packages/EasterEgg/res/drawable/leg1.xml
new file mode 100644
index 0000000000000..d72c746b62322
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/leg1.xml
@@ -0,0 +1,22 @@
+
+
+
+
diff --git a/packages/EasterEgg/res/drawable/leg2.xml b/packages/EasterEgg/res/drawable/leg2.xml
new file mode 100644
index 0000000000000..a772a870af7d1
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/leg2.xml
@@ -0,0 +1,22 @@
+
+
+
+
diff --git a/packages/EasterEgg/res/drawable/leg2_shadow.xml b/packages/EasterEgg/res/drawable/leg2_shadow.xml
new file mode 100644
index 0000000000000..b01bd6995c0b1
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/leg2_shadow.xml
@@ -0,0 +1,22 @@
+
+
+
+
diff --git a/packages/EasterEgg/res/drawable/leg3.xml b/packages/EasterEgg/res/drawable/leg3.xml
new file mode 100644
index 0000000000000..d471236687b58
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/leg3.xml
@@ -0,0 +1,22 @@
+
+
+
+
diff --git a/packages/EasterEgg/res/drawable/leg4.xml b/packages/EasterEgg/res/drawable/leg4.xml
new file mode 100644
index 0000000000000..e5868eb80c598
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/leg4.xml
@@ -0,0 +1,22 @@
+
+
+
+
diff --git a/packages/EasterEgg/res/drawable/mouth.xml b/packages/EasterEgg/res/drawable/mouth.xml
new file mode 100644
index 0000000000000..ddcf2e82f976b
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/mouth.xml
@@ -0,0 +1,27 @@
+
+
+
+
diff --git a/packages/EasterEgg/res/drawable/nose.xml b/packages/EasterEgg/res/drawable/nose.xml
new file mode 100644
index 0000000000000..d403cd1baadfe
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/nose.xml
@@ -0,0 +1,22 @@
+
+
+
+
diff --git a/packages/EasterEgg/res/drawable/octo_bg.xml b/packages/EasterEgg/res/drawable/octo_bg.xml
new file mode 100644
index 0000000000000..1e46cf434a8be
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/octo_bg.xml
@@ -0,0 +1,8 @@
+
+
+
+
\ No newline at end of file
diff --git a/packages/EasterEgg/res/drawable/right_ear.xml b/packages/EasterEgg/res/drawable/right_ear.xml
new file mode 100644
index 0000000000000..b9fb4d1c74708
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/right_ear.xml
@@ -0,0 +1,22 @@
+
+
+
+
diff --git a/packages/EasterEgg/res/drawable/right_ear_inside.xml b/packages/EasterEgg/res/drawable/right_ear_inside.xml
new file mode 100644
index 0000000000000..86b6e3428d1f3
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/right_ear_inside.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
diff --git a/packages/EasterEgg/res/drawable/right_eye.xml b/packages/EasterEgg/res/drawable/right_eye.xml
new file mode 100644
index 0000000000000..a1871a62c25bb
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/right_eye.xml
@@ -0,0 +1,22 @@
+
+
+
+
diff --git a/packages/EasterEgg/res/drawable/stat_icon.xml b/packages/EasterEgg/res/drawable/stat_icon.xml
new file mode 100644
index 0000000000000..608cb2017c3f0
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/stat_icon.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
diff --git a/packages/EasterEgg/res/drawable/tail.xml b/packages/EasterEgg/res/drawable/tail.xml
new file mode 100644
index 0000000000000..0cca23c3e16cb
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/tail.xml
@@ -0,0 +1,26 @@
+
+
+
+
diff --git a/packages/EasterEgg/res/drawable/tail_cap.xml b/packages/EasterEgg/res/drawable/tail_cap.xml
new file mode 100644
index 0000000000000..b82f6f9b478ad
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/tail_cap.xml
@@ -0,0 +1,22 @@
+
+
+
+
diff --git a/packages/EasterEgg/res/drawable/tail_shadow.xml b/packages/EasterEgg/res/drawable/tail_shadow.xml
new file mode 100644
index 0000000000000..bb1ff12b3afed
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/tail_shadow.xml
@@ -0,0 +1,22 @@
+
+
+
+
diff --git a/packages/EasterEgg/res/layout/activity_paint.xml b/packages/EasterEgg/res/layout/activity_paint.xml
index a4c17afd15313..8e916b021bbdc 100644
--- a/packages/EasterEgg/res/layout/activity_paint.xml
+++ b/packages/EasterEgg/res/layout/activity_paint.xml
@@ -16,7 +16,7 @@
-->
-
\ No newline at end of file
+
diff --git a/packages/EasterEgg/res/layout/cat_view.xml b/packages/EasterEgg/res/layout/cat_view.xml
new file mode 100644
index 0000000000000..85b494d2e68d3
--- /dev/null
+++ b/packages/EasterEgg/res/layout/cat_view.xml
@@ -0,0 +1,82 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/EasterEgg/res/layout/edit_text.xml b/packages/EasterEgg/res/layout/edit_text.xml
new file mode 100644
index 0000000000000..9f7ac802bad4a
--- /dev/null
+++ b/packages/EasterEgg/res/layout/edit_text.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/packages/EasterEgg/res/layout/food_layout.xml b/packages/EasterEgg/res/layout/food_layout.xml
new file mode 100644
index 0000000000000..d0ca0c8899aae
--- /dev/null
+++ b/packages/EasterEgg/res/layout/food_layout.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
diff --git a/packages/EasterEgg/res/layout/neko_activity.xml b/packages/EasterEgg/res/layout/neko_activity.xml
new file mode 100644
index 0000000000000..c258137ca7108
--- /dev/null
+++ b/packages/EasterEgg/res/layout/neko_activity.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/packages/EasterEgg/res/values/cat_strings.xml b/packages/EasterEgg/res/values/cat_strings.xml
new file mode 100644
index 0000000000000..5214fc1ab01d9
--- /dev/null
+++ b/packages/EasterEgg/res/values/cat_strings.xml
@@ -0,0 +1,71 @@
+
+
+ Android Neko
+ New cats
+ \????
+ A cat is here.
+ Cat #%s
+ Cats
+ Forget %s?
+
+ - Empty dish
+ - Bits
+ - Fish
+ - Chicken
+ - Treat
+
+
+ - @drawable/food_dish
+ - @drawable/food_bits
+ - @drawable/food_sysuituna
+ - @drawable/food_chicken
+ - @drawable/food_cookie
+
+
+ - 0
+ - 15
+ - 30
+ - 60
+ - 120
+
+
+ - 0
+ - 5
+ - 35
+ - 65
+ - 90
+
+
+ - 😸
+ - 😹
+ - 😺
+ - 😻
+ - 😼
+ - 😽
+ - 😾
+ - 😿
+ - 🙀
+ - 💩
+ - 🐁
+
+
+ - 🍩
+ - 🍭
+ - 🍫
+ - 🍨
+ - 🔔
+ - 🐝
+ - 🍪
+ - 🥧
+
+ Toy
+ Tap to use
+ Cat attracted!
+ Water bubbler
+ Swipe to fill
+ Food bowl
+ Tap to refill
+ Full
+ Empty
+
+
diff --git a/packages/EasterEgg/res/values/dimens.xml b/packages/EasterEgg/res/values/dimens.xml
new file mode 100644
index 0000000000000..e9dcebd27f7b2
--- /dev/null
+++ b/packages/EasterEgg/res/values/dimens.xml
@@ -0,0 +1,19 @@
+
+
+
+ 64dp
+
diff --git a/packages/EasterEgg/res/values/strings.xml b/packages/EasterEgg/res/values/strings.xml
index b95ec6be4c845..25f94215d433d 100644
--- a/packages/EasterEgg/res/values/strings.xml
+++ b/packages/EasterEgg/res/values/strings.xml
@@ -14,11 +14,13 @@ Copyright (C) 2018 The Android Open Source Project
limitations under the License.
-->
- Android Q Easter Egg
+ Android R Easter Egg
Icon Quiz
PAINT.APK
+
+ Cat Controls
diff --git a/packages/EasterEgg/res/xml/filepaths.xml b/packages/EasterEgg/res/xml/filepaths.xml
new file mode 100644
index 0000000000000..2130025e92659
--- /dev/null
+++ b/packages/EasterEgg/res/xml/filepaths.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/packages/EasterEgg/src/com/android/egg/neko/Cat.java b/packages/EasterEgg/src/com/android/egg/neko/Cat.java
new file mode 100644
index 0000000000000..cd59a735068bb
--- /dev/null
+++ b/packages/EasterEgg/src/com/android/egg/neko/Cat.java
@@ -0,0 +1,524 @@
+/*
+ * Copyright (C) 2020 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.egg.neko;
+
+import static com.android.egg.neko.NekoLand.CHAN_ID;
+
+import android.app.Notification;
+import android.app.PendingIntent;
+import android.app.Person;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.ShortcutInfo;
+import android.content.pm.ShortcutManager;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.ColorFilter;
+import android.graphics.PixelFormat;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.Icon;
+import android.os.Bundle;
+
+import com.android.egg.R;
+import com.android.internal.logging.MetricsLogger;
+
+import java.io.ByteArrayOutputStream;
+import java.lang.reflect.InvocationTargetException;
+import java.util.List;
+import java.util.Random;
+import java.util.concurrent.ThreadLocalRandom;
+
+/** It's a cat. */
+public class Cat extends Drawable {
+ public static final long[] PURR = {0, 40, 20, 40, 20, 40, 20, 40, 20, 40, 20, 40};
+
+ public static final boolean ALL_CATS_IN_ONE_CONVERSATION = true;
+
+ public static final String GLOBAL_SHORTCUT_ID = "com.android.egg.neko:allcats";
+ public static final String SHORTCUT_ID_PREFIX = "com.android.egg.neko:cat:";
+
+ private Random mNotSoRandom;
+ private Bitmap mBitmap;
+ private long mSeed;
+ private String mName;
+ private int mBodyColor;
+ private int mFootType;
+ private boolean mBowTie;
+ private String mFirstMessage;
+
+ private synchronized Random notSoRandom(long seed) {
+ if (mNotSoRandom == null) {
+ mNotSoRandom = new Random();
+ mNotSoRandom.setSeed(seed);
+ }
+ return mNotSoRandom;
+ }
+
+ public static final float frandrange(Random r, float a, float b) {
+ return (b - a) * r.nextFloat() + a;
+ }
+
+ public static final Object choose(Random r, Object... l) {
+ return l[r.nextInt(l.length)];
+ }
+
+ public static final int chooseP(Random r, int[] a) {
+ return chooseP(r, a, 1000);
+ }
+
+ public static final int chooseP(Random r, int[] a, int sum) {
+ int pct = r.nextInt(sum);
+ final int stop = a.length - 2;
+ int i = 0;
+ while (i < stop) {
+ pct -= a[i];
+ if (pct < 0) break;
+ i += 2;
+ }
+ return a[i + 1];
+ }
+
+ public static final int getColorIndex(int q, int[] a) {
+ for (int i = 1; i < a.length; i += 2) {
+ if (a[i] == q) {
+ return i / 2;
+ }
+ }
+ return -1;
+ }
+
+ public static final int[] P_BODY_COLORS = {
+ 180, 0xFF212121, // black
+ 180, 0xFFFFFFFF, // white
+ 140, 0xFF616161, // gray
+ 140, 0xFF795548, // brown
+ 100, 0xFF90A4AE, // steel
+ 100, 0xFFFFF9C4, // buff
+ 100, 0xFFFF8F00, // orange
+ 5, 0xFF29B6F6, // blue..?
+ 5, 0xFFFFCDD2, // pink!?
+ 5, 0xFFCE93D8, // purple?!?!?
+ 4, 0xFF43A047, // yeah, why not green
+ 1, 0, // ?!?!?!
+ };
+
+ public static final int[] P_COLLAR_COLORS = {
+ 250, 0xFFFFFFFF,
+ 250, 0xFF000000,
+ 250, 0xFFF44336,
+ 50, 0xFF1976D2,
+ 50, 0xFFFDD835,
+ 50, 0xFFFB8C00,
+ 50, 0xFFF48FB1,
+ 50, 0xFF4CAF50,
+ };
+
+ public static final int[] P_BELLY_COLORS = {
+ 750, 0,
+ 250, 0xFFFFFFFF,
+ };
+
+ public static final int[] P_DARK_SPOT_COLORS = {
+ 700, 0,
+ 250, 0xFF212121,
+ 50, 0xFF6D4C41,
+ };
+
+ public static final int[] P_LIGHT_SPOT_COLORS = {
+ 700, 0,
+ 300, 0xFFFFFFFF,
+ };
+
+ private CatParts D;
+
+ public static void tint(int color, Drawable... ds) {
+ for (Drawable d : ds) {
+ if (d != null) {
+ d.mutate().setTint(color);
+ }
+ }
+ }
+
+ public static boolean isDark(int color) {
+ final int r = (color & 0xFF0000) >> 16;
+ final int g = (color & 0x00FF00) >> 8;
+ final int b = color & 0x0000FF;
+ return (r + g + b) < 0x80;
+ }
+
+ public Cat(Context context, long seed) {
+ D = new CatParts(context);
+ mSeed = seed;
+
+ setName(context.getString(R.string.default_cat_name,
+ String.valueOf(mSeed % 1000)));
+
+ final Random nsr = notSoRandom(seed);
+
+ // body color
+ mBodyColor = chooseP(nsr, P_BODY_COLORS);
+ if (mBodyColor == 0) mBodyColor = Color.HSVToColor(new float[]{
+ nsr.nextFloat() * 360f, frandrange(nsr, 0.5f, 1f), frandrange(nsr, 0.5f, 1f)});
+
+ tint(mBodyColor, D.body, D.head, D.leg1, D.leg2, D.leg3, D.leg4, D.tail,
+ D.leftEar, D.rightEar, D.foot1, D.foot2, D.foot3, D.foot4, D.tailCap);
+ tint(0x20000000, D.leg2Shadow, D.tailShadow);
+ if (isDark(mBodyColor)) {
+ tint(0xFFFFFFFF, D.leftEye, D.rightEye, D.mouth, D.nose);
+ }
+ tint(isDark(mBodyColor) ? 0xFFEF9A9A : 0x20D50000, D.leftEarInside, D.rightEarInside);
+
+ tint(chooseP(nsr, P_BELLY_COLORS), D.belly);
+ tint(chooseP(nsr, P_BELLY_COLORS), D.back);
+ final int faceColor = chooseP(nsr, P_BELLY_COLORS);
+ tint(faceColor, D.faceSpot);
+ if (!isDark(faceColor)) {
+ tint(0xFF000000, D.mouth, D.nose);
+ }
+
+ mFootType = 0;
+ if (nsr.nextFloat() < 0.25f) {
+ mFootType = 4;
+ tint(0xFFFFFFFF, D.foot1, D.foot2, D.foot3, D.foot4);
+ } else {
+ if (nsr.nextFloat() < 0.25f) {
+ mFootType = 2;
+ tint(0xFFFFFFFF, D.foot1, D.foot3);
+ } else if (nsr.nextFloat() < 0.25f) {
+ mFootType = 3; // maybe -2 would be better? meh.
+ tint(0xFFFFFFFF, D.foot2, D.foot4);
+ } else if (nsr.nextFloat() < 0.1f) {
+ mFootType = 1;
+ tint(0xFFFFFFFF, (Drawable) choose(nsr, D.foot1, D.foot2, D.foot3, D.foot4));
+ }
+ }
+
+ tint(nsr.nextFloat() < 0.333f ? 0xFFFFFFFF : mBodyColor, D.tailCap);
+
+ final int capColor = chooseP(nsr, isDark(mBodyColor) ? P_LIGHT_SPOT_COLORS : P_DARK_SPOT_COLORS);
+ tint(capColor, D.cap);
+ //tint(chooseP(nsr, isDark(bodyColor) ? P_LIGHT_SPOT_COLORS : P_DARK_SPOT_COLORS), D.nose);
+
+ final int collarColor = chooseP(nsr, P_COLLAR_COLORS);
+ tint(collarColor, D.collar);
+ mBowTie = nsr.nextFloat() < 0.1f;
+ tint(mBowTie ? collarColor : 0, D.bowtie);
+
+ String[] messages = context.getResources().getStringArray(
+ nsr.nextFloat() < 0.1f ? R.array.rare_cat_messages : R.array.cat_messages);
+ mFirstMessage = (String) choose(nsr, (Object[]) messages);
+ if (nsr.nextFloat() < 0.5f) mFirstMessage = mFirstMessage + mFirstMessage + mFirstMessage;
+ }
+
+ public static Cat fromShortcutId(Context context, String shortcutId) {
+ if (shortcutId.startsWith(SHORTCUT_ID_PREFIX)) {
+ return new Cat(context, Long.parseLong(shortcutId.replace(SHORTCUT_ID_PREFIX, "")));
+ }
+ return null;
+ }
+
+ public static Cat create(Context context) {
+ return new Cat(context, Math.abs(ThreadLocalRandom.current().nextInt()));
+ }
+
+ public Notification.Builder buildNotification(Context context) {
+ final Bundle extras = new Bundle();
+ extras.putString("android.substName", context.getString(R.string.notification_name));
+
+ final Icon notificationIcon = createNotificationLargeIcon(context);
+
+ final Intent intent = new Intent(Intent.ACTION_MAIN)
+ .setClass(context, NekoLand.class)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+
+ ShortcutInfo shortcut = new ShortcutInfo.Builder(context, getShortcutId())
+ .setActivity(intent.getComponent())
+ .setIntent(intent)
+ .setShortLabel(getName())
+ .setIcon(createShortcutIcon(context))
+ .setLongLived(true)
+ .build();
+ context.getSystemService(ShortcutManager.class).addDynamicShortcuts(List.of(shortcut));
+
+ Notification.BubbleMetadata bubbs = new Notification.BubbleMetadata.Builder()
+ .setIntent(
+ PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE))
+ .setIcon(notificationIcon)
+ .setSuppressNotification(false)
+ .setDesiredHeight(context.getResources().getDisplayMetrics().heightPixels)
+ .build();
+
+ return new Notification.Builder(context, CHAN_ID)
+ .setSmallIcon(Icon.createWithResource(context, R.drawable.stat_icon))
+ .setLargeIcon(notificationIcon)
+ .setColor(getBodyColor())
+ .setContentTitle(context.getString(R.string.notification_title))
+ .setShowWhen(true)
+ .setCategory(Notification.CATEGORY_STATUS)
+ .setContentText(getName())
+ .setContentIntent(
+ PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE))
+ .setAutoCancel(true)
+ .setStyle(new Notification.MessagingStyle(createPerson())
+ .addMessage(mFirstMessage, System.currentTimeMillis(), createPerson())
+ .setConversationTitle(getName())
+ )
+ .setBubbleMetadata(bubbs)
+ .setShortcutId(getShortcutId())
+ .addExtras(extras);
+ }
+
+ private Person createPerson() {
+ return new Person.Builder()
+ .setName(getName())
+ .setBot(true)
+ .setKey(getShortcutId())
+ .build();
+ }
+
+ public long getSeed() {
+ return mSeed;
+ }
+
+ @Override
+ public void draw(Canvas canvas) {
+ final int w = Math.min(canvas.getWidth(), canvas.getHeight());
+ final int h = w;
+
+ if (mBitmap == null || mBitmap.getWidth() != w || mBitmap.getHeight() != h) {
+ mBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
+ final Canvas bitCanvas = new Canvas(mBitmap);
+ slowDraw(bitCanvas, 0, 0, w, h);
+ }
+ canvas.drawBitmap(mBitmap, 0, 0, null);
+ }
+
+ private void slowDraw(Canvas canvas, int x, int y, int w, int h) {
+ for (int i = 0; i < D.drawingOrder.length; i++) {
+ final Drawable d = D.drawingOrder[i];
+ if (d != null) {
+ d.setBounds(x, y, x + w, y + h);
+ d.draw(canvas);
+ }
+ }
+
+ }
+
+ public Bitmap createBitmap(int w, int h) {
+ if (mBitmap != null && mBitmap.getWidth() == w && mBitmap.getHeight() == h) {
+ return mBitmap.copy(mBitmap.getConfig(), true);
+ }
+ Bitmap result = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
+ slowDraw(new Canvas(result), 0, 0, w, h);
+ return result;
+ }
+
+ public static Icon recompressIcon(Icon bitmapIcon) {
+ if (bitmapIcon.getType() != Icon.TYPE_BITMAP) return bitmapIcon;
+ try {
+ final Bitmap bits = (Bitmap) Icon.class.getDeclaredMethod("getBitmap").invoke(bitmapIcon);
+ final ByteArrayOutputStream ostream = new ByteArrayOutputStream(
+ bits.getWidth() * bits.getHeight() * 2); // guess 50% compression
+ final boolean ok = bits.compress(Bitmap.CompressFormat.PNG, 100, ostream);
+ if (!ok) return null;
+ return Icon.createWithData(ostream.toByteArray(), 0, ostream.size());
+ } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException ex) {
+ return bitmapIcon;
+ }
+ }
+
+ public Icon createNotificationLargeIcon(Context context) {
+ final Resources res = context.getResources();
+ final int w = res.getDimensionPixelSize(android.R.dimen.notification_large_icon_width);
+ final int h = res.getDimensionPixelSize(android.R.dimen.notification_large_icon_height);
+ return recompressIcon(createIcon(context, w, h));
+ }
+
+ public Icon createShortcutIcon(Context context) {
+ // shortcuts do not support compressed bitmaps
+ final Resources res = context.getResources();
+ final int w = res.getDimensionPixelSize(android.R.dimen.notification_large_icon_width);
+ final int h = res.getDimensionPixelSize(android.R.dimen.notification_large_icon_height);
+ return createIcon(context, w, h);
+ }
+
+ public Icon createIcon(Context context, int w, int h) {
+ Bitmap result = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
+ final Canvas canvas = new Canvas(result);
+ float[] hsv = new float[3];
+ Color.colorToHSV(mBodyColor, hsv);
+ hsv[2] = (hsv[2] > 0.5f)
+ ? (hsv[2] - 0.25f)
+ : (hsv[2] + 0.25f);
+ //final Paint pt = new Paint();
+ //pt.setColor(Color.HSVToColor(hsv));
+ //float r = w/2;
+ //canvas.drawCircle(r, r, r, pt);
+ // int m = w/10;
+
+ // Adaptive bitmaps!
+ canvas.drawColor(Color.HSVToColor(hsv));
+ int m = w / 4;
+
+ slowDraw(canvas, m, m, w - m - m, h - m - m);
+
+ return Icon.createWithAdaptiveBitmap(result);
+ }
+
+ @Override
+ public void setAlpha(int i) {
+
+ }
+
+ @Override
+ public void setColorFilter(ColorFilter colorFilter) {
+
+ }
+
+ @Override
+ public int getOpacity() {
+ return PixelFormat.TRANSLUCENT;
+ }
+
+ public String getName() {
+ return mName;
+ }
+
+ public void setName(String name) {
+ this.mName = name;
+ }
+
+ public int getBodyColor() {
+ return mBodyColor;
+ }
+
+ public void logAdd(Context context) {
+ logCatAction(context, "egg_neko_add");
+ }
+
+ public void logRename(Context context) {
+ logCatAction(context, "egg_neko_rename");
+ }
+
+ public void logRemove(Context context) {
+ logCatAction(context, "egg_neko_remove");
+ }
+
+ public void logShare(Context context) {
+ logCatAction(context, "egg_neko_share");
+ }
+
+ private void logCatAction(Context context, String prefix) {
+ MetricsLogger.count(context, prefix, 1);
+ MetricsLogger.histogram(context, prefix + "_color",
+ getColorIndex(mBodyColor, P_BODY_COLORS));
+ MetricsLogger.histogram(context, prefix + "_bowtie", mBowTie ? 1 : 0);
+ MetricsLogger.histogram(context, prefix + "_feet", mFootType);
+ }
+
+ public String getShortcutId() {
+ return ALL_CATS_IN_ONE_CONVERSATION
+ ? GLOBAL_SHORTCUT_ID
+ : (SHORTCUT_ID_PREFIX + mSeed);
+ }
+
+ public static class CatParts {
+ public Drawable leftEar;
+ public Drawable rightEar;
+ public Drawable rightEarInside;
+ public Drawable leftEarInside;
+ public Drawable head;
+ public Drawable faceSpot;
+ public Drawable cap;
+ public Drawable mouth;
+ public Drawable body;
+ public Drawable foot1;
+ public Drawable leg1;
+ public Drawable foot2;
+ public Drawable leg2;
+ public Drawable foot3;
+ public Drawable leg3;
+ public Drawable foot4;
+ public Drawable leg4;
+ public Drawable tail;
+ public Drawable leg2Shadow;
+ public Drawable tailShadow;
+ public Drawable tailCap;
+ public Drawable belly;
+ public Drawable back;
+ public Drawable rightEye;
+ public Drawable leftEye;
+ public Drawable nose;
+ public Drawable bowtie;
+ public Drawable collar;
+ public Drawable[] drawingOrder;
+
+ public CatParts(Context context) {
+ body = context.getDrawable(R.drawable.body);
+ head = context.getDrawable(R.drawable.head);
+ leg1 = context.getDrawable(R.drawable.leg1);
+ leg2 = context.getDrawable(R.drawable.leg2);
+ leg3 = context.getDrawable(R.drawable.leg3);
+ leg4 = context.getDrawable(R.drawable.leg4);
+ tail = context.getDrawable(R.drawable.tail);
+ leftEar = context.getDrawable(R.drawable.left_ear);
+ rightEar = context.getDrawable(R.drawable.right_ear);
+ rightEarInside = context.getDrawable(R.drawable.right_ear_inside);
+ leftEarInside = context.getDrawable(R.drawable.left_ear_inside);
+ faceSpot = context.getDrawable(R.drawable.face_spot);
+ cap = context.getDrawable(R.drawable.cap);
+ mouth = context.getDrawable(R.drawable.mouth);
+ foot4 = context.getDrawable(R.drawable.foot4);
+ foot3 = context.getDrawable(R.drawable.foot3);
+ foot1 = context.getDrawable(R.drawable.foot1);
+ foot2 = context.getDrawable(R.drawable.foot2);
+ leg2Shadow = context.getDrawable(R.drawable.leg2_shadow);
+ tailShadow = context.getDrawable(R.drawable.tail_shadow);
+ tailCap = context.getDrawable(R.drawable.tail_cap);
+ belly = context.getDrawable(R.drawable.belly);
+ back = context.getDrawable(R.drawable.back);
+ rightEye = context.getDrawable(R.drawable.right_eye);
+ leftEye = context.getDrawable(R.drawable.left_eye);
+ nose = context.getDrawable(R.drawable.nose);
+ collar = context.getDrawable(R.drawable.collar);
+ bowtie = context.getDrawable(R.drawable.bowtie);
+ drawingOrder = getDrawingOrder();
+ }
+
+ private Drawable[] getDrawingOrder() {
+ return new Drawable[]{
+ collar,
+ leftEar, leftEarInside, rightEar, rightEarInside,
+ head,
+ faceSpot,
+ cap,
+ leftEye, rightEye,
+ nose, mouth,
+ tail, tailCap, tailShadow,
+ foot1, leg1,
+ foot2, leg2,
+ foot3, leg3,
+ foot4, leg4,
+ leg2Shadow,
+ body, belly,
+ bowtie
+ };
+ }
+ }
+}
diff --git a/packages/EasterEgg/src/com/android/egg/neko/Food.java b/packages/EasterEgg/src/com/android/egg/neko/Food.java
new file mode 100644
index 0000000000000..aeffc4adfd3a6
--- /dev/null
+++ b/packages/EasterEgg/src/com/android/egg/neko/Food.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright (C) 2020 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.egg.neko;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.drawable.Icon;
+
+import com.android.egg.R;
+
+public class Food {
+ private final int mType;
+
+ private static int[] sIcons;
+ private static String[] sNames;
+
+ public Food(int type) {
+ mType = type;
+ }
+
+ public Icon getIcon(Context context) {
+ if (sIcons == null) {
+ TypedArray icons = context.getResources().obtainTypedArray(R.array.food_icons);
+ sIcons = new int[icons.length()];
+ for (int i = 0; i < sIcons.length; i++) {
+ sIcons[i] = icons.getResourceId(i, 0);
+ }
+ icons.recycle();
+ }
+ return Icon.createWithResource(context, sIcons[mType]);
+ }
+
+ public String getName(Context context) {
+ if (sNames == null) {
+ sNames = context.getResources().getStringArray(R.array.food_names);
+ }
+ return sNames[mType];
+ }
+
+ public long getInterval(Context context) {
+ return context.getResources().getIntArray(R.array.food_intervals)[mType];
+ }
+
+ public int getType() {
+ return mType;
+ }
+}
diff --git a/packages/EasterEgg/src/com/android/egg/neko/NekoActivationActivity.java b/packages/EasterEgg/src/com/android/egg/neko/NekoActivationActivity.java
new file mode 100644
index 0000000000000..df461c6878f0c
--- /dev/null
+++ b/packages/EasterEgg/src/com/android/egg/neko/NekoActivationActivity.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright (C) 2016 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.egg.neko;
+
+import android.app.Activity;
+import android.content.ComponentName;
+import android.content.pm.PackageManager;
+import android.provider.Settings;
+import android.util.Log;
+import android.widget.Toast;
+
+import com.android.internal.logging.MetricsLogger;
+
+public class NekoActivationActivity extends Activity {
+ private static final String R_EGG_UNLOCK_SETTING = "egg_mode_r";
+
+ private void toastUp(String s) {
+ Toast toast = Toast.makeText(this, s, Toast.LENGTH_SHORT);
+ toast.show();
+ }
+
+ @Override
+ public void onStart() {
+ super.onStart();
+
+ final PackageManager pm = getPackageManager();
+ final ComponentName cn = new ComponentName(this, NekoControlsService.class);
+ final boolean componentEnabled = pm.getComponentEnabledSetting(cn)
+ == PackageManager.COMPONENT_ENABLED_STATE_ENABLED;
+ if (Settings.System.getLong(getContentResolver(),
+ R_EGG_UNLOCK_SETTING, 0) == 0) {
+ if (componentEnabled) {
+ Log.v("Neko", "Disabling controls.");
+ pm.setComponentEnabledSetting(cn, PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
+ PackageManager.DONT_KILL_APP);
+ MetricsLogger.histogram(this, "egg_neko_enable", 0);
+ toastUp("\uD83D\uDEAB");
+ } else {
+ Log.v("Neko", "Controls already disabled.");
+ }
+ } else {
+ if (!componentEnabled) {
+ Log.v("Neko", "Enabling controls.");
+ pm.setComponentEnabledSetting(cn, PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
+ PackageManager.DONT_KILL_APP);
+ MetricsLogger.histogram(this, "egg_neko_enable", 1);
+ toastUp("\uD83D\uDC31");
+ } else {
+ Log.v("Neko", "Controls already enabled.");
+ }
+ }
+ finish();
+ }
+}
diff --git a/packages/EasterEgg/src/com/android/egg/neko/NekoControlsService.kt b/packages/EasterEgg/src/com/android/egg/neko/NekoControlsService.kt
new file mode 100644
index 0000000000000..56f599a3a2198
--- /dev/null
+++ b/packages/EasterEgg/src/com/android/egg/neko/NekoControlsService.kt
@@ -0,0 +1,323 @@
+/*
+ * Copyright (C) 2020 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.egg.neko
+
+import android.app.PendingIntent
+import android.content.Intent
+import android.content.res.ColorStateList
+import android.graphics.drawable.Icon
+import android.service.controls.Control
+import android.service.controls.ControlsProviderService
+import android.service.controls.DeviceTypes
+import android.service.controls.actions.ControlAction
+import android.service.controls.actions.FloatAction
+import android.service.controls.templates.ControlButton
+import android.service.controls.templates.RangeTemplate
+import android.service.controls.templates.StatelessTemplate
+import android.service.controls.templates.ToggleTemplate
+import android.text.SpannableStringBuilder
+import android.text.style.ForegroundColorSpan
+import android.util.Log
+import androidx.annotation.RequiresApi
+import com.android.internal.logging.MetricsLogger
+import java.util.Random
+import java.util.concurrent.Flow
+import java.util.function.Consumer
+
+import com.android.egg.R
+
+const val CONTROL_ID_WATER = "water"
+const val CONTROL_ID_FOOD = "food"
+const val CONTROL_ID_TOY = "toy"
+
+const val FOOD_SPAWN_CAT_DELAY_MINS = 5L
+
+const val COLOR_FOOD_FG = 0xFFFF8000.toInt()
+const val COLOR_FOOD_BG = COLOR_FOOD_FG and 0x40FFFFFF.toInt()
+const val COLOR_WATER_FG = 0xFF0080FF.toInt()
+const val COLOR_WATER_BG = COLOR_WATER_FG and 0x40FFFFFF.toInt()
+const val COLOR_TOY_FG = 0xFFFF4080.toInt()
+const val COLOR_TOY_BG = COLOR_TOY_FG and 0x40FFFFFF.toInt()
+
+val P_TOY_ICONS = intArrayOf(
+ 1, R.drawable.ic_toy_mouse,
+ 1, R.drawable.ic_toy_fish,
+ 1, R.drawable.ic_toy_ball,
+ 1, R.drawable.ic_toy_laser
+)
+
+@RequiresApi(30)
+fun Control_toString(control: Control): String {
+ val hc = String.format("0x%08x", control.hashCode())
+ return ("Control($hc id=${control.controlId}, type=${control.deviceType}, " +
+ "title=${control.title}, template=${control.controlTemplate})")
+}
+
+@RequiresApi(30)
+public class NekoControlsService : ControlsProviderService(), PrefState.PrefsListener {
+ private val TAG = "NekoControls"
+
+ private val controls = HashMap()
+ private val publishers = ArrayList()
+ private val rng = Random()
+
+ private var lastToyIcon: Icon? = null
+
+ private lateinit var prefs: PrefState
+
+ override fun onCreate() {
+ super.onCreate()
+
+ prefs = PrefState(this)
+ prefs.setListener(this)
+
+ createDefaultControls()
+ }
+
+ override fun onPrefsChanged() {
+ createDefaultControls()
+ }
+
+ private fun createDefaultControls() {
+ val foodState: Int = prefs.foodState
+ if (foodState != 0) {
+ NekoService.registerJobIfNeeded(this, FOOD_SPAWN_CAT_DELAY_MINS)
+ }
+
+ val water = prefs.waterState
+
+ controls[CONTROL_ID_WATER] = makeWaterBowlControl(water)
+ controls[CONTROL_ID_FOOD] = makeFoodBowlControl(foodState != 0)
+ controls[CONTROL_ID_TOY] = makeToyControl(currentToyIcon(), false)
+ }
+
+ private fun currentToyIcon(): Icon {
+ val icon = lastToyIcon ?: randomToyIcon()
+ lastToyIcon = icon
+ return icon
+ }
+
+ private fun randomToyIcon(): Icon {
+ return Icon.createWithResource(resources, Cat.chooseP(rng, P_TOY_ICONS, 4))
+ }
+
+ private fun colorize(s: CharSequence, color: Int): CharSequence {
+ val ssb = SpannableStringBuilder(s)
+ ssb.setSpan(ForegroundColorSpan(color), 0, s.length, 0)
+ return ssb
+ }
+
+ private fun makeToyControl(icon: Icon?, thrown: Boolean): Control {
+ return Control.StatefulBuilder(CONTROL_ID_TOY, getPendingIntent())
+ .setDeviceType(DeviceTypes.TYPE_UNKNOWN)
+ .setCustomIcon(icon)
+ // ?.setTint(COLOR_TOY_FG)) // TODO(b/159559045): uncomment when fixed
+ .setCustomColor(ColorStateList.valueOf(COLOR_TOY_BG))
+ .setTitle(colorize(getString(R.string.control_toy_title), COLOR_TOY_FG))
+ .setStatusText(colorize(
+ if (thrown) getString(R.string.control_toy_status) else "",
+ COLOR_TOY_FG))
+ .setControlTemplate(StatelessTemplate("toy"))
+ .setStatus(Control.STATUS_OK)
+ .setSubtitle(if (thrown) "" else getString(R.string.control_toy_subtitle))
+ .setAppIntent(getAppIntent())
+ .build()
+ }
+
+ private fun makeWaterBowlControl(fillLevel: Float): Control {
+ return Control.StatefulBuilder(CONTROL_ID_WATER, getPendingIntent())
+ .setDeviceType(DeviceTypes.TYPE_KETTLE)
+ .setTitle(colorize(getString(R.string.control_water_title), COLOR_WATER_FG))
+ .setCustomColor(ColorStateList.valueOf(COLOR_WATER_BG))
+ .setCustomIcon(Icon.createWithResource(resources,
+ if (fillLevel >= 100f) R.drawable.ic_water_filled else R.drawable.ic_water))
+ //.setTint(COLOR_WATER_FG)) // TODO(b/159559045): uncomment when fixed
+ .setControlTemplate(RangeTemplate("waterlevel", 0f, 200f, fillLevel, 10f,
+ "%.0f mL"))
+ .setStatus(Control.STATUS_OK)
+ .setSubtitle(if (fillLevel == 0f) getString(R.string.control_water_subtitle) else "")
+ .build()
+ }
+
+ private fun makeFoodBowlControl(filled: Boolean): Control {
+ return Control.StatefulBuilder(CONTROL_ID_FOOD, getPendingIntent())
+ .setDeviceType(DeviceTypes.TYPE_UNKNOWN)
+ .setCustomColor(ColorStateList.valueOf(COLOR_FOOD_BG))
+ .setTitle(colorize(getString(R.string.control_food_title), COLOR_FOOD_FG))
+ .setCustomIcon(Icon.createWithResource(resources,
+ if (filled) R.drawable.ic_foodbowl_filled else R.drawable.ic_bowl))
+ // .setTint(COLOR_FOOD_FG)) // TODO(b/159559045): uncomment when fixed
+ .setStatusText(
+ if (filled) colorize(
+ getString(R.string.control_food_status_full), 0xCCFFFFFF.toInt())
+ else colorize(
+ getString(R.string.control_food_status_empty), 0x80FFFFFF.toInt()))
+ .setControlTemplate(ToggleTemplate("foodbowl", ControlButton(filled, "Refill")))
+ .setStatus(Control.STATUS_OK)
+ .setSubtitle(if (filled) "" else getString(R.string.control_food_subtitle))
+ .build()
+ }
+
+ private fun getPendingIntent(): PendingIntent {
+ val intent = Intent(Intent.ACTION_MAIN)
+ .setClass(this, NekoLand::class.java)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ return PendingIntent.getActivity(this, 0, intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
+ }
+
+ private fun getAppIntent(): PendingIntent {
+ return getPendingIntent()
+ }
+
+
+ override fun performControlAction(
+ controlId: String,
+ action: ControlAction,
+ consumer: Consumer
+ ) {
+ when (controlId) {
+ CONTROL_ID_FOOD -> {
+ // refill bowl
+ controls[CONTROL_ID_FOOD] = makeFoodBowlControl(true)
+ Log.v(TAG, "Bowl refilled. (Registering job.)")
+ NekoService.registerJob(this, FOOD_SPAWN_CAT_DELAY_MINS)
+ MetricsLogger.histogram(this, "egg_neko_offered_food", 11)
+ prefs.foodState = 11
+ }
+ CONTROL_ID_TOY -> {
+ Log.v(TAG, "Toy tossed.")
+ controls[CONTROL_ID_TOY] =
+ makeToyControl(currentToyIcon(), true)
+ // TODO: re-enable toy
+ Thread() {
+ Thread.sleep((1 + Random().nextInt(4)) * 1000L)
+ NekoService.getExistingCat(prefs)?.let {
+ NekoService.notifyCat(this, it)
+ }
+ controls[CONTROL_ID_TOY] = makeToyControl(randomToyIcon(), false)
+ pushControlChanges()
+ }.start()
+ }
+ CONTROL_ID_WATER -> {
+ if (action is FloatAction) {
+ controls[CONTROL_ID_WATER] = makeWaterBowlControl(action.newValue)
+ Log.v(TAG, "Water level set to " + action.newValue)
+ prefs.waterState = action.newValue
+ }
+ }
+ else -> {
+ return
+ }
+ }
+ consumer.accept(ControlAction.RESPONSE_OK)
+ pushControlChanges()
+ }
+
+ private fun pushControlChanges() {
+ Thread() {
+ publishers.forEach { it.refresh() }
+ }.start()
+ }
+
+ private fun makeStateless(c: Control?): Control? {
+ if (c == null) return null
+ return Control.StatelessBuilder(c.controlId, c.appIntent)
+ .setTitle(c.title)
+ .setSubtitle(c.subtitle)
+ .setStructure(c.structure)
+ .setDeviceType(c.deviceType)
+ .setCustomIcon(c.customIcon)
+ .setCustomColor(c.customColor)
+ .build()
+ }
+
+ override fun createPublisherFor(list: MutableList): Flow.Publisher {
+ createDefaultControls()
+
+ val publisher = UglyPublisher(list, true)
+ publishers.add(publisher)
+ return publisher
+ }
+
+ override fun createPublisherForAllAvailable(): Flow.Publisher {
+ createDefaultControls()
+
+ val publisher = UglyPublisher(controls.keys, false)
+ publishers.add(publisher)
+ return publisher
+ }
+
+ private inner class UglyPublisher(
+ val controlKeys: Iterable,
+ val indefinite: Boolean
+ ) : Flow.Publisher {
+ val subscriptions = ArrayList()
+
+ private inner class UglySubscription(
+ val initialControls: Iterator,
+ var subscriber: Flow.Subscriber?
+ ) : Flow.Subscription {
+ override fun cancel() {
+ Log.v(TAG, "cancel subscription: $this for subscriber: $subscriber " +
+ "to publisher: $this@UglyPublisher")
+ subscriber = null
+ unsubscribe(this)
+ }
+
+ override fun request(p0: Long) {
+ (0 until p0).forEach { _ ->
+ if (initialControls.hasNext()) {
+ send(initialControls.next())
+ } else {
+ if (!indefinite) subscriber?.onComplete()
+ }
+ }
+ }
+
+ fun send(c: Control) {
+ Log.v(TAG, "sending update: " + Control_toString(c) + " => " + subscriber)
+ subscriber?.onNext(c)
+ }
+ }
+
+ override fun subscribe(subscriber: Flow.Subscriber) {
+ Log.v(TAG, "subscribe to publisher: $this by subscriber: $subscriber")
+ val sub = UglySubscription(controlKeys.mapNotNull { controls[it] }.iterator(),
+ subscriber)
+ subscriptions.add(sub)
+ subscriber.onSubscribe(sub)
+ }
+
+ fun unsubscribe(sub: UglySubscription) {
+ Log.v(TAG, "no more subscriptions, removing subscriber: $sub")
+ subscriptions.remove(sub)
+ if (subscriptions.size == 0) {
+ Log.v(TAG, "no more subscribers, removing publisher: $this")
+ publishers.remove(this)
+ }
+ }
+
+ fun refresh() {
+ controlKeys.mapNotNull { controls[it] }.forEach { control ->
+ subscriptions.forEach { sub ->
+ sub.send(control)
+ }
+ }
+ }
+ }
+}
diff --git a/packages/EasterEgg/src/com/android/egg/neko/NekoDialog.java b/packages/EasterEgg/src/com/android/egg/neko/NekoDialog.java
new file mode 100644
index 0000000000000..2bd2228e7bf21
--- /dev/null
+++ b/packages/EasterEgg/src/com/android/egg/neko/NekoDialog.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright (C) 2020 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.egg.neko;
+
+import android.app.Dialog;
+import android.content.Context;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.GridLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.android.egg.R;
+
+import java.util.ArrayList;
+
+public class NekoDialog extends Dialog {
+
+ private final Adapter mAdapter;
+
+ public NekoDialog(@NonNull Context context) {
+ super(context, android.R.style.Theme_Material_Dialog_NoActionBar);
+ RecyclerView view = new RecyclerView(getContext());
+ mAdapter = new Adapter(getContext());
+ view.setLayoutManager(new GridLayoutManager(getContext(), 2));
+ view.setAdapter(mAdapter);
+ final float dp = context.getResources().getDisplayMetrics().density;
+ final int pad = (int)(16*dp);
+ view.setPadding(pad, pad, pad, pad);
+ setContentView(view);
+ }
+
+ private void onFoodSelected(Food food) {
+ PrefState prefs = new PrefState(getContext());
+ int currentState = prefs.getFoodState();
+ if (currentState == 0 && food.getType() != 0) {
+ NekoService.registerJob(getContext(), food.getInterval(getContext()));
+ }
+// MetricsLogger.histogram(getContext(), "egg_neko_offered_food", food.getType());
+ prefs.setFoodState(food.getType());
+ dismiss();
+ }
+
+ private class Adapter extends RecyclerView.Adapter {
+
+ private final Context mContext;
+ private final ArrayList mFoods = new ArrayList<>();
+
+ public Adapter(Context context) {
+ mContext = context;
+ int[] foods = context.getResources().getIntArray(R.array.food_names);
+ // skip food 0, you can't choose it
+ for (int i=1; i list = mPrefs.getCats();
+ Collections.sort(list, new Comparator() {
+ @Override
+ public int compare(Cat cat, Cat cat2) {
+ Color.colorToHSV(cat.getBodyColor(), hsv);
+ float bodyH1 = hsv[0];
+ Color.colorToHSV(cat2.getBodyColor(), hsv);
+ float bodyH2 = hsv[0];
+ return Float.compare(bodyH1, bodyH2);
+ }
+ });
+ cats = list.toArray(new Cat[0]);
+ }
+ mAdapter.setCats(cats);
+ return cats.length;
+ }
+
+ private void onCatClick(Cat cat) {
+ if (CAT_GEN) {
+ mPrefs.addCat(cat);
+ new AlertDialog.Builder(NekoLand.this)
+ .setTitle("Cat added")
+ .setPositiveButton(android.R.string.ok, null)
+ .show();
+ } else {
+ showNameDialog(cat);
+ }
+ }
+
+ private void onCatRemove(Cat cat) {
+ cat.logRemove(this);
+ mPrefs.removeCat(cat);
+ }
+
+ private void showNameDialog(final Cat cat) {
+ final Context context = new ContextThemeWrapper(this,
+ android.R.style.Theme_Material_Light_Dialog_NoActionBar);
+ // TODO: Move to XML, add correct margins.
+ View view = LayoutInflater.from(context).inflate(R.layout.edit_text, null);
+ final EditText text = (EditText) view.findViewById(android.R.id.edit);
+ text.setText(cat.getName());
+ text.setSelection(cat.getName().length());
+ final int size = context.getResources()
+ .getDimensionPixelSize(android.R.dimen.app_icon_size);
+ Drawable catIcon = cat.createIcon(this, size, size).loadDrawable(this);
+ new AlertDialog.Builder(context)
+ .setTitle(" ")
+ .setIcon(catIcon)
+ .setView(view)
+ .setPositiveButton(android.R.string.ok, new OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ cat.logRename(context);
+ cat.setName(text.getText().toString().trim());
+ mPrefs.addCat(cat);
+ }
+ }).show();
+ }
+
+ @Override
+ public void onPrefsChanged() {
+ updateCats();
+ }
+
+ private class CatAdapter extends RecyclerView.Adapter {
+
+ private Cat[] mCats;
+
+ public void setCats(Cat[] cats) {
+ mCats = cats;
+ notifyDataSetChanged();
+ }
+
+ @Override
+ public CatHolder onCreateViewHolder(ViewGroup parent, int viewType) {
+ return new CatHolder(LayoutInflater.from(parent.getContext())
+ .inflate(R.layout.cat_view, parent, false));
+ }
+
+ private void setContextGroupVisible(final CatHolder holder, boolean vis) {
+ final View group = holder.contextGroup;
+ if (vis && group.getVisibility() != View.VISIBLE) {
+ group.setAlpha(0);
+ group.setVisibility(View.VISIBLE);
+ group.animate().alpha(1.0f).setDuration(333);
+ Runnable hideAction = new Runnable() {
+ @Override
+ public void run() {
+ setContextGroupVisible(holder, false);
+ }
+ };
+ group.setTag(hideAction);
+ group.postDelayed(hideAction, 5000);
+ } else if (!vis && group.getVisibility() == View.VISIBLE) {
+ group.removeCallbacks((Runnable) group.getTag());
+ group.animate().alpha(0f).setDuration(250).withEndAction(new Runnable() {
+ @Override
+ public void run() {
+ group.setVisibility(View.INVISIBLE);
+ }
+ });
+ }
+ }
+
+ @Override
+ public void onBindViewHolder(final CatHolder holder, int position) {
+ Context context = holder.itemView.getContext();
+ final int size = context.getResources().getDimensionPixelSize(R.dimen.neko_display_size);
+ holder.imageView.setImageIcon(mCats[position].createIcon(context, size, size));
+ holder.textView.setText(mCats[position].getName());
+ holder.itemView.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ onCatClick(mCats[holder.getAdapterPosition()]);
+ }
+ });
+ holder.itemView.setOnLongClickListener(new OnLongClickListener() {
+ @Override
+ public boolean onLongClick(View v) {
+ setContextGroupVisible(holder, true);
+ return true;
+ }
+ });
+ holder.delete.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ setContextGroupVisible(holder, false);
+ new AlertDialog.Builder(NekoLand.this)
+ .setTitle(getString(R.string.confirm_delete, mCats[position].getName()))
+ .setNegativeButton(android.R.string.cancel, null)
+ .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ onCatRemove(mCats[holder.getAdapterPosition()]);
+ }
+ })
+ .show();
+ }
+ });
+ holder.share.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ setContextGroupVisible(holder, false);
+ Cat cat = mCats[holder.getAdapterPosition()];
+ if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)
+ != PackageManager.PERMISSION_GRANTED) {
+ mPendingShareCat = cat;
+ requestPermissions(
+ new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE},
+ STORAGE_PERM_REQUEST);
+ return;
+ }
+ shareCat(cat);
+ }
+ });
+ }
+
+ @Override
+ public int getItemCount() {
+ return mCats.length;
+ }
+ }
+
+ private void shareCat(Cat cat) {
+ final File dir = new File(
+ Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES),
+ "Cats");
+ if (!dir.exists() && !dir.mkdirs()) {
+ Log.e("NekoLand", "save: error: can't create Pictures directory");
+ return;
+ }
+ final File png = new File(dir, cat.getName().replaceAll("[/ #:]+", "_") + ".png");
+ Bitmap bitmap = cat.createBitmap(EXPORT_BITMAP_SIZE, EXPORT_BITMAP_SIZE);
+ if (bitmap != null) {
+ try {
+ OutputStream os = new FileOutputStream(png);
+ bitmap.compress(Bitmap.CompressFormat.PNG, 0, os);
+ os.close();
+ MediaScannerConnection.scanFile(
+ this,
+ new String[]{png.toString()},
+ new String[]{"image/png"},
+ null);
+ Log.v("Neko", "cat file: " + png);
+ Uri uri = FileProvider.getUriForFile(this, "com.android.egg.fileprovider", png);
+ Log.v("Neko", "cat uri: " + uri);
+ Intent intent = new Intent(Intent.ACTION_SEND);
+ intent.putExtra(Intent.EXTRA_STREAM, uri);
+ intent.putExtra(Intent.EXTRA_SUBJECT, cat.getName());
+ intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
+ intent.setType("image/png");
+ startActivity(Intent.createChooser(intent, null)
+ .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION));
+ cat.logShare(this);
+ } catch (IOException e) {
+ Log.e("NekoLand", "save: error: " + e);
+ }
+ }
+ }
+
+ @Override
+ public void onRequestPermissionsResult(int requestCode,
+ String permissions[], int[] grantResults) {
+ if (requestCode == STORAGE_PERM_REQUEST) {
+ if (mPendingShareCat != null) {
+ shareCat(mPendingShareCat);
+ mPendingShareCat = null;
+ }
+ }
+ }
+
+ private static class CatHolder extends RecyclerView.ViewHolder {
+ private final ImageView imageView;
+ private final TextView textView;
+ private final View contextGroup;
+ private final View delete;
+ private final View share;
+
+ public CatHolder(View itemView) {
+ super(itemView);
+ imageView = (ImageView) itemView.findViewById(android.R.id.icon);
+ textView = (TextView) itemView.findViewById(android.R.id.title);
+ contextGroup = itemView.findViewById(R.id.contextGroup);
+ delete = itemView.findViewById(android.R.id.closeButton);
+ share = itemView.findViewById(android.R.id.shareText);
+ }
+ }
+}
diff --git a/packages/EasterEgg/src/com/android/egg/neko/NekoLockedActivity.java b/packages/EasterEgg/src/com/android/egg/neko/NekoLockedActivity.java
new file mode 100644
index 0000000000000..ca89adc7b6e4d
--- /dev/null
+++ b/packages/EasterEgg/src/com/android/egg/neko/NekoLockedActivity.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright (C) 2020 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.egg.neko;
+
+import android.app.Activity;
+import android.content.DialogInterface;
+import android.content.DialogInterface.OnDismissListener;
+import android.os.Bundle;
+import android.view.WindowManager;
+
+import androidx.annotation.Nullable;
+
+public class NekoLockedActivity extends Activity implements OnDismissListener {
+
+ private NekoDialog mDialog;
+
+ @Override
+ public void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ getWindow().addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED);
+ getWindow().addFlags(WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD);
+ getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+ getWindow().addFlags(WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON);
+
+ mDialog = new NekoDialog(this);
+ mDialog.setOnDismissListener(this);
+ mDialog.show();
+ }
+
+ @Override
+ public void onDismiss(DialogInterface dialog) {
+ finish();
+ }
+}
diff --git a/packages/EasterEgg/src/com/android/egg/neko/NekoService.java b/packages/EasterEgg/src/com/android/egg/neko/NekoService.java
new file mode 100644
index 0000000000000..939e85c07d068
--- /dev/null
+++ b/packages/EasterEgg/src/com/android/egg/neko/NekoService.java
@@ -0,0 +1,193 @@
+/*
+ * Copyright (C) 2020 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.egg.neko;
+
+import static com.android.egg.neko.Cat.PURR;
+import static com.android.egg.neko.NekoLand.CHAN_ID;
+
+import android.app.Notification;
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.app.job.JobInfo;
+import android.app.job.JobParameters;
+import android.app.job.JobScheduler;
+import android.app.job.JobService;
+import android.content.ComponentName;
+import android.content.Context;
+import android.net.Uri;
+import android.os.Bundle;
+import android.util.Log;
+
+import com.android.egg.R;
+
+import java.util.List;
+import java.util.Random;
+
+public class NekoService extends JobService {
+
+ private static final String TAG = "NekoService";
+
+ public static int JOB_ID = 42;
+
+ public static int CAT_NOTIFICATION = 1;
+ public static int DEBUG_NOTIFICATION = 1234;
+
+ public static float CAT_CAPTURE_PROB = 1.0f; // generous
+
+ public static long SECONDS = 1000;
+ public static long MINUTES = 60 * SECONDS;
+
+ //public static long INTERVAL_FLEX = 15 * SECONDS;
+ public static long INTERVAL_FLEX = 5 * MINUTES;
+
+ public static float INTERVAL_JITTER_FRAC = 0.25f;
+
+ private static void setupNotificationChannels(Context context) {
+ NotificationManager noman = context.getSystemService(NotificationManager.class);
+ NotificationChannel eggChan = new NotificationChannel(CHAN_ID,
+ context.getString(R.string.notification_channel_name),
+ NotificationManager.IMPORTANCE_DEFAULT);
+ eggChan.setSound(Uri.EMPTY, Notification.AUDIO_ATTRIBUTES_DEFAULT); // cats are quiet
+ eggChan.setVibrationPattern(PURR); // not totally quiet though
+ //eggChan.setBlockableSystem(true); // unlike a real cat, you can push this one off your lap
+ eggChan.setLockscreenVisibility(Notification.VISIBILITY_PUBLIC); // cats sit in the window
+ noman.createNotificationChannel(eggChan);
+ }
+
+ @Override
+ public boolean onStartJob(JobParameters params) {
+ Log.v(TAG, "Starting job: " + String.valueOf(params));
+
+ if (NekoLand.DEBUG_NOTIFICATIONS) {
+ NotificationManager noman = getSystemService(NotificationManager.class);
+ final Bundle extras = new Bundle();
+ extras.putString("android.substName", getString(R.string.notification_name));
+ final int size = getResources()
+ .getDimensionPixelSize(android.R.dimen.notification_large_icon_width);
+ final Cat cat = Cat.create(this);
+ final Notification.Builder builder
+ = cat.buildNotification(this)
+ .setContentTitle("DEBUG")
+ .setChannelId(NekoLand.CHAN_ID)
+ .setContentText("Ran job: " + params);
+
+ noman.notify(DEBUG_NOTIFICATION, builder.build());
+ }
+
+ triggerFoodResponse(this);
+ cancelJob(this);
+ return false;
+ }
+
+ private static void triggerFoodResponse(Context context) {
+ final PrefState prefs = new PrefState(context);
+ int food = prefs.getFoodState();
+ if (food != 0) {
+ prefs.setFoodState(0); // nom
+ final Random rng = new Random();
+ if (rng.nextFloat() <= CAT_CAPTURE_PROB) {
+ Cat cat;
+ List cats = prefs.getCats();
+ final int[] probs = context.getResources().getIntArray(R.array.food_new_cat_prob);
+ final float waterLevel100 = prefs.getWaterState() / 2; // water is 0..200
+ final float new_cat_prob = (float) ((food < probs.length)
+ ? probs[food]
+ : waterLevel100) / 100f;
+ Log.v(TAG, "Food type: " + food);
+ Log.v(TAG, "New cat probability: " + new_cat_prob);
+
+ if (cats.size() == 0 || rng.nextFloat() <= new_cat_prob) {
+ cat = newRandomCat(context, prefs);
+ Log.v(TAG, "A new cat is here: " + cat.getName());
+ } else {
+ cat = getExistingCat(prefs);
+ Log.v(TAG, "A cat has returned: " + cat.getName());
+ }
+
+ notifyCat(context, cat);
+ }
+ }
+ }
+
+ static void notifyCat(Context context, Cat cat) {
+ NotificationManager noman = context.getSystemService(NotificationManager.class);
+ final Notification.Builder builder = cat.buildNotification(context);
+ noman.notify(cat.getShortcutId(), CAT_NOTIFICATION, builder.build());
+ }
+
+ static Cat newRandomCat(Context context, PrefState prefs) {
+ final Cat cat = Cat.create(context);
+ prefs.addCat(cat);
+ cat.logAdd(context);
+ return cat;
+ }
+
+ static Cat getExistingCat(PrefState prefs) {
+ final List cats = prefs.getCats();
+ if (cats.size() == 0) return null;
+ return cats.get(new Random().nextInt(cats.size()));
+ }
+
+ @Override
+ public boolean onStopJob(JobParameters jobParameters) {
+ return false;
+ }
+
+ public static void registerJobIfNeeded(Context context, long intervalMinutes) {
+ JobScheduler jss = context.getSystemService(JobScheduler.class);
+ JobInfo info = jss.getPendingJob(JOB_ID);
+ if (info == null) {
+ registerJob(context, intervalMinutes);
+ }
+ }
+
+ public static void registerJob(Context context, long intervalMinutes) {
+ setupNotificationChannels(context);
+
+ JobScheduler jss = context.getSystemService(JobScheduler.class);
+ jss.cancel(JOB_ID);
+ long interval = intervalMinutes * MINUTES;
+ long jitter = (long) (INTERVAL_JITTER_FRAC * interval);
+ interval += (long) (Math.random() * (2 * jitter)) - jitter;
+ final JobInfo jobInfo = new JobInfo.Builder(JOB_ID,
+ new ComponentName(context, NekoService.class))
+ .setPeriodic(interval, INTERVAL_FLEX)
+ .build();
+
+ Log.v(TAG, "A cat will visit in " + interval + "ms: " + String.valueOf(jobInfo));
+ jss.schedule(jobInfo);
+
+ if (NekoLand.DEBUG_NOTIFICATIONS) {
+ NotificationManager noman = context.getSystemService(NotificationManager.class);
+ noman.notify(DEBUG_NOTIFICATION, new Notification.Builder(context)
+ .setSmallIcon(R.drawable.stat_icon)
+ .setContentTitle(String.format("Job scheduled in %d min", (interval / MINUTES)))
+ .setContentText(String.valueOf(jobInfo))
+ .setPriority(Notification.PRIORITY_MIN)
+ .setCategory(Notification.CATEGORY_SERVICE)
+ .setChannelId(NekoLand.CHAN_ID)
+ .setShowWhen(true)
+ .build());
+ }
+ }
+
+ public static void cancelJob(Context context) {
+ JobScheduler jss = context.getSystemService(JobScheduler.class);
+ Log.v(TAG, "Canceling job");
+ jss.cancel(JOB_ID);
+ }
+}
diff --git a/packages/EasterEgg/src/com/android/egg/neko/NekoTile.java b/packages/EasterEgg/src/com/android/egg/neko/NekoTile.java
new file mode 100644
index 0000000000000..d02433f40e897
--- /dev/null
+++ b/packages/EasterEgg/src/com/android/egg/neko/NekoTile.java
@@ -0,0 +1,116 @@
+/*
+ * Copyright (C) 2020 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.egg.neko;
+
+import android.content.Intent;
+import android.service.quicksettings.Tile;
+import android.service.quicksettings.TileService;
+import android.util.Log;
+
+import com.android.egg.neko.PrefState.PrefsListener;
+import com.android.internal.logging.MetricsLogger;
+
+public class NekoTile extends TileService implements PrefsListener {
+
+ private static final String TAG = "NekoTile";
+
+ private PrefState mPrefs;
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ mPrefs = new PrefState(this);
+ }
+
+ @Override
+ public void onStartListening() {
+ super.onStartListening();
+ mPrefs.setListener(this);
+ updateState();
+ }
+
+ @Override
+ public void onStopListening() {
+ super.onStopListening();
+ mPrefs.setListener(null);
+ }
+
+ @Override
+ public void onTileAdded() {
+ super.onTileAdded();
+ MetricsLogger.count(this, "egg_neko_tile_added", 1);
+ }
+
+ @Override
+ public void onTileRemoved() {
+ super.onTileRemoved();
+ MetricsLogger.count(this, "egg_neko_tile_removed", 1);
+ }
+
+ @Override
+ public void onPrefsChanged() {
+ updateState();
+ }
+
+ private void updateState() {
+ Tile tile = getQsTile();
+ int foodState = mPrefs.getFoodState();
+ Food food = new Food(foodState);
+ if (foodState != 0) {
+ NekoService.registerJobIfNeeded(this, food.getInterval(this));
+ }
+ tile.setIcon(food.getIcon(this));
+ tile.setLabel(food.getName(this));
+ tile.setState(foodState != 0 ? Tile.STATE_ACTIVE : Tile.STATE_INACTIVE);
+ tile.updateTile();
+ }
+
+ @Override
+ public void onClick() {
+ if (mPrefs.getFoodState() != 0) {
+ // there's already food loaded, let's empty it
+ MetricsLogger.count(this, "egg_neko_empty_food", 1);
+ mPrefs.setFoodState(0);
+ NekoService.cancelJob(this);
+ } else {
+ // time to feed the cats
+ if (isLocked()) {
+ if (isSecure()) {
+ Log.d(TAG, "startActivityAndCollapse");
+ Intent intent = new Intent(this, NekoLockedActivity.class);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ startActivityAndCollapse(intent);
+ } else {
+ unlockAndRun(new Runnable() {
+ @Override
+ public void run() {
+ showNekoDialog();
+ }
+ });
+ }
+ } else {
+ showNekoDialog();
+ }
+ }
+ }
+
+ private void showNekoDialog() {
+ Log.d(TAG, "showNekoDialog");
+ MetricsLogger.count(this, "egg_neko_select_food", 1);
+ showDialog(new NekoDialog(this));
+ }
+}
diff --git a/packages/EasterEgg/src/com/android/egg/neko/PrefState.java b/packages/EasterEgg/src/com/android/egg/neko/PrefState.java
new file mode 100644
index 0000000000000..49ff315392b4d
--- /dev/null
+++ b/packages/EasterEgg/src/com/android/egg/neko/PrefState.java
@@ -0,0 +1,104 @@
+/*
+ * Copyright (C) 2020 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.egg.neko;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.content.SharedPreferences.OnSharedPreferenceChangeListener;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+public class PrefState implements OnSharedPreferenceChangeListener {
+
+ private static final String FILE_NAME = "mPrefs";
+
+ private static final String FOOD_STATE = "food";
+
+ private static final String WATER_STATE = "water";
+
+ private static final String CAT_KEY_PREFIX = "cat:";
+
+ private final Context mContext;
+ private final SharedPreferences mPrefs;
+ private PrefsListener mListener;
+
+ public PrefState(Context context) {
+ mContext = context;
+ mPrefs = mContext.getSharedPreferences(FILE_NAME, 0);
+ }
+
+ // Can also be used for renaming.
+ public void addCat(Cat cat) {
+ mPrefs.edit()
+ .putString(CAT_KEY_PREFIX + String.valueOf(cat.getSeed()), cat.getName())
+ .apply();
+ }
+
+ public void removeCat(Cat cat) {
+ mPrefs.edit().remove(CAT_KEY_PREFIX + String.valueOf(cat.getSeed())).apply();
+ }
+
+ public List getCats() {
+ ArrayList cats = new ArrayList<>();
+ Map map = mPrefs.getAll();
+ for (String key : map.keySet()) {
+ if (key.startsWith(CAT_KEY_PREFIX)) {
+ long seed = Long.parseLong(key.substring(CAT_KEY_PREFIX.length()));
+ Cat cat = new Cat(mContext, seed);
+ cat.setName(String.valueOf(map.get(key)));
+ cats.add(cat);
+ }
+ }
+ return cats;
+ }
+
+ public int getFoodState() {
+ return mPrefs.getInt(FOOD_STATE, 0);
+ }
+
+ public void setFoodState(int foodState) {
+ mPrefs.edit().putInt(FOOD_STATE, foodState).apply();
+ }
+
+ public float getWaterState() {
+ return mPrefs.getFloat(WATER_STATE, 0f);
+ }
+
+ public void setWaterState(float waterState) {
+ mPrefs.edit().putFloat(WATER_STATE, waterState).apply();
+ }
+
+ public void setListener(PrefsListener listener) {
+ mListener = listener;
+ if (mListener != null) {
+ mPrefs.registerOnSharedPreferenceChangeListener(this);
+ } else {
+ mPrefs.unregisterOnSharedPreferenceChangeListener(this);
+ }
+ }
+
+ @Override
+ public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
+ mListener.onPrefsChanged();
+ }
+
+ public interface PrefsListener {
+ void onPrefsChanged();
+ }
+}