| /* |
| * 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<String, Control>() |
| private val publishers = ArrayList<UglyPublisher>() |
| 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<Int> |
| ) { |
| 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<String>): Flow.Publisher<Control> { |
| createDefaultControls() |
| |
| val publisher = UglyPublisher(list, true) |
| publishers.add(publisher) |
| return publisher |
| } |
| |
| override fun createPublisherForAllAvailable(): Flow.Publisher<Control> { |
| createDefaultControls() |
| |
| val publisher = UglyPublisher(controls.keys, false) |
| publishers.add(publisher) |
| return publisher |
| } |
| |
| private inner class UglyPublisher( |
| val controlKeys: Iterable<String>, |
| val indefinite: Boolean |
| ) : Flow.Publisher<Control> { |
| val subscriptions = ArrayList<UglySubscription>() |
| |
| private inner class UglySubscription( |
| val initialControls: Iterator<Control>, |
| var subscriber: Flow.Subscriber<in Control>? |
| ) : 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<in Control>) { |
| 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) |
| } |
| } |
| } |
| } |
| } |