שליטה במכשירים חיצוניים

ב-Android 11 ואילך, התכונה 'גישה מהירה לממשק השליטה במכשירים' מאפשרת למשתמשים להציג ולשלוט במהירות במכשירים חיצוניים כמו מנורות, תרמוסטטים ומצלמות, באמצעות ממשק משתמש שמופיע תוך שלוש אינטראקציות מתוך מרכז האפליקציות שמוגדר כברירת מחדל. יצרן המכשיר בוחר את אפליקציית ההפעלה שבה הוא משתמש. מצביעים על מכשירים להצגה במרחב הזה – לדוגמה, Google Home – ואפליקציות של ספקי צד שלישי. בדף הזה מוסבר איך להציג את ממשק השליטה במכשירים במרחב הזה ולקשר אותם לאפליקציית הבקרה.

איור 1. האזור של אמצעי הבקרה של המכשיר בממשק המשתמש של Android.

כדי להוסיף את התמיכה הזו, צריך ליצור ולהצהיר על ControlsProviderService. יוצרים את אמצעי הבקרה שהאפליקציה תומכת בהם על סמך סוגי אמצעי בקרה מוגדרים מראש, ואז יוצרים מוציאים לאור עבור אמצעי הבקרה האלה.

ממשק משתמש

המכשירים מוצגים בקטע אמצעי בקרה למכשירים כווידג'טים מבוססי-תבניות. יש חמישה ווידג'טים של בקרת מכשירים, כמו שמוצג באיור הבא:

החלפת מצב של הווידג'ט
החלפה
החלפת מצב באמצעות ווידג'ט של מחוון
הפעלה או השבתה באמצעות פס הזזה
ווידג'ט של טווח
טווח (אי אפשר להפעיל או להשבית)
ווידג'ט החלפה ללא מצב
מתג ללא שמירת מצב
ווידג'ט של חלונית הטמפרטורה (סגור)
חלונית הטמפרטורה (סגורה)
איור 2. אוסף של ווידג'טים מבוססי-תבניות.

אם לוחצים לחיצה ארוכה על ווידג'ט, עוברים לאפליקציה כדי לקבל שליטה מעמיקה יותר. אפשר להתאים אישית את הסמל והצבע בכל ווידג'ט, אבל כדי להעניק את חוויית המשתמש הטובה ביותר, כדאי להשתמש בסמל ובצבע שמוגדרים כברירת מחדל אם הם תואמים למכשיר.

תמונה שבה מוצג הווידג'ט של חלונית הטמפרטורה (פתוח)
איור 3. פותחים את הווידג'ט של חלונית הטמפרטורה.

יצירת השירות

בקטע הזה נסביר איך ליצור את ControlsProviderService. השירות הזה מודיע לממשק המשתמש של מערכת Android שהאפליקציה מכילה אמצעי בקרה למכשיר שצריכים להופיע באזור אמצעי הבקרה למכשיר בממשק המשתמש של Android.

ה-API של ControlsProviderService מתבסס על ההנחה שאתם מכירים את הזרמים הריאקטיביים, כפי שהם מוגדרים בפרויקט Reactive Streams ב-GitHub וכפי שהם מיושמים בממשקי Java 9 Flow. ה-API מבוסס על המושגים הבאים:

  • בעל האתר: האפליקציה שלך היא בעלת האתר.
  • מנוי: ממשק המשתמש של המערכת הוא המנוי, והוא יכול לבקש מספר אמצעי בקרה מהאתר.
  • מינוי: פרק הזמן שבמהלכו בעל התוכן הדיגיטלי יכול לשלוח עדכונים לממשק המשתמש של המערכת. בעל האפליקציה או המנוי יכולים לסגור את החלון הזה.

הצהרה על השירות

האפליקציה שלך חייבת להצהיר על שירות – כמו MyCustomControlService – בקובץ מניפסט של אפליקציה.

השירות חייב לכלול מסנן Intent עבור ControlsProviderService. המסנן הזה מאפשר לאפליקציות להוסיף אמצעי בקרה לממשק המשתמש של המערכת.

צריך גם label שמוצג בפקדים בממשק המשתמש של המערכת.

בדוגמה הבאה אפשר לראות איך מכריזים על שירות:

<service
    android:name="MyCustomControlService"
    android:label="My Custom Controls"
    android:permission="android.permission.BIND_CONTROLS"
    android:exported="true"
    >
    <intent-filter>
      <action android:name="android.service.controls.ControlsProviderService" />
    </intent-filter>
</service>

אחר כך, יוצרים קובץ Kotlin חדש בשם MyCustomControlService.kt ומוסיפים לו את ההרחבה ControlsProviderService():

Kotlin

    class MyCustomControlService : ControlsProviderService() {
        ...
    }
    

Java

    public class MyCustomJavaControlService extends ControlsProviderService {
        ...
    }
    

בחירה של סוג אמצעי הבקרה הנכון

ה-API מספק שיטות לבנייה כדי ליצור את אמצעי הבקרה. כדי לאכלס את הכלי לבניית תכונות, צריך לקבוע את המכשיר שרוצים לשלוט בו ואת אופן האינטראקציה של המשתמש איתו. כך עושים את זה:

  1. בוחרים את סוג המכשיר שאמצעי הבקרה מייצג. הכיתה DeviceTypes היא ספירה של כל המכשירים הנתמכים. הסוג משמש לקביעת הסמלים והצבעים של המכשיר בממשק המשתמש.
  2. קובעים את השם שמוצג למשתמש, את מיקום המכשיר – למשל, מטבח – ואלמנטים טקסטואליים אחרים בממשק המשתמש שמשויכים לאמצעי הבקרה.
  3. בוחרים את התבנית הכי טובה לתמיכה באינטראקציה של המשתמשים. פקדים מקבלים ControlTemplate מהאפליקציה. בתבנית הזו מוצג ישירות למשתמש מצב הבקרה וגם שיטות הקלט הזמינות – כלומר, ControlAction. בטבלה הבאה מפורטות כמה מהתבניות הזמינות והפעולות שהן תומכות בהן:
תבנית פעולה תיאור
ControlTemplate.getNoTemplateObject() None יכול להיות שהאפליקציה תשתמש בזה כדי להעביר מידע על אמצעי הבקרה, אבל המשתמש לא יכול ליצור איתו אינטראקציה.
ToggleTemplate BooleanAction מייצג רכיב בקרה שאפשר להעביר בין מצב מופעל למצב מושבת. האובייקט BooleanAction מכיל שדה שמשתנה כדי לייצג את המצב החדש המבוקש כשהמשתמש מקיש על אמצעי הבקרה.
RangeTemplate FloatAction מייצג ווידג'ט של פס הזזה עם ערכי מינימום, מקסימום ושלב שצוינו. כשמשתמש יוצר אינטראקציה עם פס ההזזה, שולחים אובייקט FloatAction חדש בחזרה לאפליקציה עם הערך המעודכן.
ToggleRangeTemplate BooleanAction, FloatAction התבנית הזו היא שילוב של ToggleTemplate ושל RangeTemplate. הוא תומך באירועי מגע וגם במכוון החלקה, למשל כדי לשלוט בעמעום של אורות.
TemperatureControlTemplate ModeAction, BooleanAction, FloatAction בנוסף לפעולות הקודמות, התבנית הזו מאפשרת למשתמש להגדיר מצב, כמו חימום, קירור, חימום/קירור, חיסכון או השבתה.
StatelessTemplate CommandAction משמש לציון אמצעי בקרה שמספק יכולת מגע אבל אי אפשר לקבוע את המצב שלו, כמו שלט רחוק לטלוויזיה עם אינפרה-אדום. אפשר להשתמש בתבנית הזו כדי להגדיר שגרה או מאקרו, שהם צבירה של שינויים בשליטה ובמצב.

בעזרת המידע הזה, אפשר ליצור את אמצעי הבקרה:

לדוגמה, כדי לשלוט בנורה חכמה ובתרמוסטט, מוסיפים את הקבועים הבאים ל-MyCustomControlService:

Kotlin

    private const val LIGHT_ID = 1234
    private const val LIGHT_TITLE = "My fancy light"
    private const val LIGHT_TYPE = DeviceTypes.TYPE_LIGHT
    private const val THERMOSTAT_ID = 5678
    private const val THERMOSTAT_TITLE = "My fancy thermostat"
    private const val THERMOSTAT_TYPE = DeviceTypes.TYPE_THERMOSTAT
 
    class MyCustomControlService : ControlsProviderService() {
      ...
    }
    

Java

    public class MyCustomJavaControlService extends ControlsProviderService {
 
    private final int LIGHT_ID = 1337;
    private final String LIGHT_TITLE = "My fancy light";
    private final int LIGHT_TYPE = DeviceTypes.TYPE_LIGHT;
    private final int THERMOSTAT_ID = 1338;
    private final String THERMOSTAT_TITLE = "My fancy thermostat";
    private final int THERMOSTAT_TYPE = DeviceTypes.TYPE_THERMOSTAT;
 
    ...
    }
    

יצירת בעלי אפליקציה עבור אמצעי הבקרה

אחרי שיוצרים את אמצעי הבקרה, צריך להגדיר לו בעל תוכן דיגיטלי. המוציא לאור מודיע לממשק המשתמש של המערכת על קיומו של אמצעי הבקרה. למחלקת ControlsProviderService יש שתי שיטות לבעלי תוכן דיגיטלי שצריך לבטל בקוד האפליקציה:

  • createPublisherForAllAvailable(): יוצר Publisher לכל אמצעי הבקרה שזמינים באפליקציה. משתמשים ב-Control.StatelessBuilder() כדי ליצור אובייקטים של Control בשביל בעל האתר הזה.
  • createPublisherFor(): יוצרת Publisher לרשימה של אמצעי בקרה נתונים, שמזוהים באמצעות מזהי המחרוזת שלהם. כדי ליצור את אובייקטי Control האלה, צריך להשתמש ב-Control.StatefulBuilder, כי בעל האתר צריך להקצות מצב לכל אמצעי בקרה.

יצירת חשבון לבעל אפליקציה

כשהאפליקציה מפרסמת אמצעי בקרה בממשק המשתמש של המערכת בפעם הראשונה, היא לא יודעת את המצב של כל אמצעי בקרה. קבלת המצב יכולה להיות פעולה שלוקחת זמן רב, שכוללת הרבה קפיצות ברשת של ספק המכשיר. משתמשים ב-method‏ createPublisherForAllAvailable() כדי לפרסם את אמצעי הבקרה הזמינים במערכת. בשיטה הזו נעשה שימוש במחלקת ה-builder‏ Control.StatelessBuilder, כי המצב של כל אמצעי בקרה לא ידוע.

אחרי שהאמצעים להפעלה מופיעים בממשק המשתמש של Android , המשתמש יכול לבחור את האמצעים המועדפים להפעלה.

כדי להשתמש ב-coroutines של Kotlin כדי ליצור ControlsProviderService, מוסיפים תלות חדשה ל-build.gradle:

Groovy

dependencies {
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-jdk9:1.6.4"
}

Kotlin

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-jdk9:1.6.4")
}

אחרי שמסנכרנים את קובצי Gradle, מוסיפים את קטע הקוד הבא לקובץ Service כדי להטמיע את createPublisherForAllAvailable():

Kotlin

    class MyCustomControlService : ControlsProviderService() {
 
      override fun createPublisherForAllAvailable(): Flow.Publisher =
          flowPublish {
              send(createStatelessControl(LIGHT_ID, LIGHT_TITLE, LIGHT_TYPE))
              send(createStatelessControl(THERMOSTAT_ID, THERMOSTAT_TITLE, THERMOSTAT_TYPE))
          }
 
      private fun createStatelessControl(id: Int, title: String, type: Int): Control {
          val intent = Intent(this, MainActivity::class.java)
              .putExtra(EXTRA_MESSAGE, title)
              .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
          val action = PendingIntent.getActivity(
              this,
              id,
              intent,
              PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
          )
 
          return Control.StatelessBuilder(id.toString(), action)
              .setTitle(title)
              .setDeviceType(type)
              .build()
      }
 
          override fun createPublisherFor(controlIds: List): Flow.Publisher {
           TODO()
        }
 
        override fun performControlAction(
            controlId: String,
            action: ControlAction,
            consumer: Consumer
        ) {
            TODO()
        }
    }
    

Java

    public class MyCustomJavaControlService extends ControlsProviderService {
 
        private final int LIGHT_ID = 1337;
        private final String LIGHT_TITLE = "My fancy light";
        private final int LIGHT_TYPE = DeviceTypes.TYPE_LIGHT;
        private final int THERMOSTAT_ID = 1338;
        private final String THERMOSTAT_TITLE = "My fancy thermostat";
        private final int THERMOSTAT_TYPE = DeviceTypes.TYPE_THERMOSTAT;
 
        private boolean toggleState = false;
        private float rangeState = 18f;
        private final Map<String, ReplayProcessor> controlFlows = new HashMap<>();
 
        @NonNull
        @Override
        public Flow.Publisher createPublisherForAllAvailable() {
            List controls = new ArrayList<>();
            controls.add(createStatelessControl(LIGHT_ID, LIGHT_TITLE, LIGHT_TYPE));
            controls.add(createStatelessControl(THERMOSTAT_ID, THERMOSTAT_TITLE, THERMOSTAT_TYPE));
            return FlowAdapters.toFlowPublisher(Flowable.fromIterable(controls));
        }
 
        @NonNull
        @Override
        public Flow.Publisher createPublisherFor(@NonNull List controlIds) {
            ReplayProcessor updatePublisher = ReplayProcessor.create();
 
            controlIds.forEach(control -> {
                controlFlows.put(control, updatePublisher);
                updatePublisher.onNext(createLight());
                updatePublisher.onNext(createThermostat());
            });
 
            return FlowAdapters.toFlowPublisher(updatePublisher);
        }
    }
    

מחליקים למטה בתפריט המערכת ומאתרים את הלחצן אמצעי בקרה למכשיר, שמוצג באיור 4:

תמונה שבה מוצג ממשק המשתמש של המערכת לשליטה במכשירים
איור 4. פקדי מכשירים בתפריט המערכת.

כשמקישים על אמצעי בקרה למכשיר, עוברים למסך שני שבו אפשר לבחור את האפליקציה. אחרי שבוחרים את האפליקציה, אפשר לראות איך קטע הקוד הקודם יוצר תפריט מערכת מותאם אישית שבו מוצגים אמצעי הבקרה החדשים, כמו שמוצג באיור 5:

תמונה שבה מוצג תפריט המערכת עם אמצעי שליטה בתאורה ובתרמוסטט
איור 5. פקדים של תאורה ותרמוסטט להוספה.

עכשיו מטמיעים את השיטה createPublisherFor() ומוסיפים את הקוד הבא ל-Service:

Kotlin

    private val job = SupervisorJob()
    private val scope = CoroutineScope(Dispatchers.IO + job)
    private val controlFlows = mutableMapOf<String, MutableSharedFlow>()
 
    private var toggleState = false
    private var rangeState = 18f
 
    override fun createPublisherFor(controlIds: List): Flow.Publisher {
        val flow = MutableSharedFlow(replay = 2, extraBufferCapacity = 2)
 
        controlIds.forEach { controlFlows[it] = flow }
 
        scope.launch {
            delay(1000) // Retrieving the toggle state.
            flow.tryEmit(createLight())
 
            delay(1000) // Retrieving the range state.
            flow.tryEmit(createThermostat())
 
        }
        return flow.asPublisher()
    }
 
    private fun createLight() = createStatefulControl(
        LIGHT_ID,
        LIGHT_TITLE,
        LIGHT_TYPE,
        toggleState,
        ToggleTemplate(
            LIGHT_ID.toString(),
            ControlButton(
                toggleState,
                toggleState.toString().uppercase(Locale.getDefault())
            )
        )
    )
 
    private fun createThermostat() = createStatefulControl(
        THERMOSTAT_ID,
        THERMOSTAT_TITLE,
        THERMOSTAT_TYPE,
        rangeState,
        RangeTemplate(
            THERMOSTAT_ID.toString(),
            15f,
            25f,
            rangeState,
            0.1f,
            "%1.1f"
        )
    )
 
    private fun  createStatefulControl(id: Int, title: String, type: Int, state: T, template: ControlTemplate): Control {
        val intent = Intent(this, MainActivity::class.java)
            .putExtra(EXTRA_MESSAGE, "$title $state")
            .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
        val action = PendingIntent.getActivity(
            this,
            id,
            intent,
            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
        )
 
        return Control.StatefulBuilder(id.toString(), action)
            .setTitle(title)
            .setDeviceType(type)
            .setStatus(Control.STATUS_OK)
            .setControlTemplate(template)
            .build()
    }
 
    override fun onDestroy() {
        super.onDestroy()
        job.cancel()
    }
 
    

Java

    @NonNull
    @Override
    public Flow.Publisher createPublisherFor(@NonNull List controlIds) {
        ReplayProcessor updatePublisher = ReplayProcessor.create();
 
        controlIds.forEach(control -> {
            controlFlows.put(control, updatePublisher);
            updatePublisher.onNext(createLight());
            updatePublisher.onNext(createThermostat());
        });
 
        return FlowAdapters.toFlowPublisher(updatePublisher);
    }
 
    private Control createStatelessControl(int id, String title, int type) {
        Intent intent = new Intent(this, MainActivity.class)
                .putExtra(EXTRA_MESSAGE, title)
                .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        PendingIntent action = PendingIntent.getActivity(
                this,
                id,
                intent,
                PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
        );
 
        return new Control.StatelessBuilder(id + "", action)
                .setTitle(title)
                .setDeviceType(type)
                .build();
    }
 
    private Control createLight() {
        return createStatefulControl(
                LIGHT_ID,
                LIGHT_TITLE,
                LIGHT_TYPE,
                toggleState,
                new ToggleTemplate(
                        LIGHT_ID + "",
                        new ControlButton(
                                toggleState,
                                String.valueOf(toggleState).toUpperCase(Locale.getDefault())
                        )
                )
        );
    }
 
    private Control createThermostat() {
        return createStatefulControl(
                THERMOSTAT_ID,
                THERMOSTAT_TITLE,
                THERMOSTAT_TYPE,
                rangeState,
                new RangeTemplate(
                        THERMOSTAT_ID + "",
                        15f,
                        25f,
                        rangeState,
                        0.1f,
                        "%1.1f"
                )
        );
    }
 
    private  Control createStatefulControl(int id, String title, int type, T state, ControlTemplate template) {
        Intent intent = new Intent(this, MainActivity.class)
                .putExtra(EXTRA_MESSAGE, "$title $state")
                .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        PendingIntent action = PendingIntent.getActivity(
                this,
                id,
                intent,
                PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
        );
 
        return new Control.StatefulBuilder(id + "", action)
                .setTitle(title)
                .setDeviceType(type)
                .setStatus(Control.STATUS_OK)
                .setControlTemplate(template)
                .build();
    }
    

בדוגמה הזו, השיטה createPublisherFor() מכילה הטמעה מזויפת של מה שהאפליקציה צריכה לעשות: לתקשר עם המכשיר כדי לאחזר את הסטטוס שלו, ולשדר את הסטטוס הזה למערכת.

ה-method‏ createPublisherFor() משתמשת ב-coroutines וב-flows של Kotlin כדי לעמוד בדרישות של Reactive Streams API. לשם כך היא מבצעת את הפעולות הבאות:

  1. יצירת Flow.
  2. הפונקציה ממתינה שנייה אחת.
  3. יוצרת את המצב של הנורה החכמה ומשדרת אותו.
  4. ממתינים עוד שנייה.
  5. יוצר ושולח את מצב התרמוסטט.

טיפול בפעולות

השיטה performControlAction() מסמנת מתי המשתמש מקיים אינטראקציה עם פקד שפורסם. סוג הControlAction שנשלח קובע את הפעולה. מבצעים את הפעולה המתאימה עבור אמצעי הבקרה הנתון ואז מעדכנים את מצב המכשיר בממשק המשתמש של Android.

כדי להשלים את הדוגמה, מוסיפים את הקוד הבא ל-Service:

Kotlin

    override fun performControlAction(
        controlId: String,
        action: ControlAction,
        consumer: Consumer
    ) {
        controlFlows[controlId]?.let { flow ->
            when (controlId) {
                LIGHT_ID.toString() -> {
                    consumer.accept(ControlAction.RESPONSE_OK)
                    if (action is BooleanAction) toggleState = action.newState
                    flow.tryEmit(createLight())
                }
                THERMOSTAT_ID.toString() -> {
                    consumer.accept(ControlAction.RESPONSE_OK)
                    if (action is FloatAction) rangeState = action.newValue
                    flow.tryEmit(createThermostat())
                }
                else -> consumer.accept(ControlAction.RESPONSE_FAIL)
            }
        } ?: consumer.accept(ControlAction.RESPONSE_FAIL)
    }
    

Java

    @Override
    public void performControlAction(@NonNull String controlId, @NonNull ControlAction action, @NonNull Consumer consumer) {
        ReplayProcessor processor = controlFlows.get(controlId);
        if (processor == null) return;
 
        if (controlId.equals(LIGHT_ID + "")) {
            consumer.accept(ControlAction.RESPONSE_OK);
            if (action instanceof BooleanAction) toggleState = ((BooleanAction) action).getNewState();
            processor.onNext(createLight());
        }
        if (controlId.equals(THERMOSTAT_ID + "")) {
            consumer.accept(ControlAction.RESPONSE_OK);
            if (action instanceof FloatAction) rangeState = ((FloatAction) action).getNewValue()
            processor.onNext(createThermostat());
        }
    }
    

מפעילים את האפליקציה, ניגשים לתפריט אמצעי הבקרה למכשירים ורואים את אמצעי הבקרה של התרמוסטט והתאורה.

תמונה שבה מוצגים אמצעי שליטה על אור ועל תרמוסטט
איור 6. שליטה בתאורה ובתרמוסטט.