Android topics — 23: תג NFC של תחנת כיתה


NDEF, יכולת מכשיר, Reader Mode וכשל תקשורת

מפת המעבדות

בסוף המעבדה תלמיד נוגע עם טלפון בתג NDEF שנושא מזהה תחנה כמו LAB-ROOM-3. האפליקציה מציגה רק מזהה תקין, מדווחת על תג שאינו NDEF או על קריאה שנקטעה, ומפסיקה Reader Mode כשעוזבים את המסך. כפתור Try a sample record מפעיל בדיוק את פענוח ה־NDEF גם באמולטור שאין בו רדיו NFC.

בסיס ההשוואה בפרויקט topics הוא master; ענף התוצאה הוא codex/nfc-classroom-tag. זהו מסלול התמחות אחד מתוך Bluetooth/NFC, עם תרחיש שימוש מפורש. כדי להוכיח את הרדיו עצמו דרושים טלפון NFC ותג NDEF פיזי; הבנייה והפענוח הסינתטי אינם ראיה לקריאת תג אמיתי.

מודל התקשורת

שלב הצלחה כשל שצריך להציג
יכולת מכשיר NfcAdapter קיים ופעיל אין שבב / NFC כבוי
זיהוי תג ReaderCallback מחזירה Tag אין תג בשדה
פתיחת NDEF Ndef.get(tag) אינו null תג בטכנולוגיה שאינה NDEF
קריאה connect() ו־getNdefMessage() תג התרחק, I/O, מבנה פגום
הבנת תוכן רשומת Text עם LAB-... URI, טקסט לא צפוי או כמה רשומות

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

זיהוי תג אינו הבנת התוכן שלו

Reader Mode מוסרת Tag, שמתאר טכנולוגיות תקשורת זמינות. Ndef.get(tag) שואלת אם אפשר לעבוד עם הודעת NDEF; getNdefMessage קוראת אותה; StationRecord.read מחליטה אם התוכן הוא מזהה תחנה שהאפליקציה מקבלת. הצלחה בשלב אחד אינה מבטיחה את הבא.

flowchart LR
    T["Radio detects Tag"] --> N["NDEF technology available?"]
    N --> I["Connect and read message on reader thread"]
    I --> P["Validate record type and payload structure"]
    P --> V["Validate station ID pattern"]
    V --> U["Post accepted station to UI"]
    S["Synthetic sample record"] --> P

החץ של הדוגמה הסינתטית מתחיל אחרי הרדיו. הוא מאפשר לבדוק את אותו parser בלי חומרה, אבל אינו בודק antenna, טווח או תג שהתרחק בזמן קריאה. לכן בדיקות parser וקריאת תג אמיתי הן שתי ראיות משלימות. finally סוגרת את החיבור גם כשקריאה נכשלה; הצלחת connect אינה מבטיחה שהתג נשאר בשדה עד סיום הקריאה.

ברשומת Text הבית הראשון כולל דגל קידוד ואורך קוד שפה. צריך לבדוק שה־payload ארוך מספיק לפני שחותכים ממנו את הטקסט; אחרת קלט פגום עלול להפוך לחריגת אינדקס. רק אחרי בדיקות TNF, סוג וקידוד נבדוק תבנית LAB-.... URI אינה Text, גם אם הטקסט המודפס שלה נראה כמו מזהה תחנה.

רשומה שמכילה מזהה תחנה תקין עדיין אינה אישור נוכחות או זכות לפתוח דלת. אפשר להעתיק את תוכנה לתג אחר. מזהה אומר “מה כתוב כאן”; אימות משתמש והרשאה אומרים “מי רשאי לפעול ומה מותר לו”. אם הופכים את המעבדה למוצר, מחזור חיי הקורא, תוצאת parser והרשאות השרת צריכים להישאר גבולות נפרדים.

עצרו ונבאו

כפתור הדוגמה מציג LAB-ROOM-3 באמולטור. מה בדיוק הוכחנו, ומה עדיין לא? כתבו תחזית לפני פתיחת ההסבר, ואז הצביעו על המשתנה או התנאי בקוד שמצדיקים אותה.

בדיקת ההבנה

הוכחנו שהודעת NDEF Text הסינתטית עוברת דרך אותו parser וה־UI מציגה את המזהה המאושר. לא בדקנו קורא NFC, טווח, תג שהתרחק או תקשורת רדיו. מזהה שאושר מבחינת מבנה גם אינו אימות זהות של אדם.

1. מתקינים גם על מכשיר ללא NFC

ב־app > manifests > AndroidManifest.xml, לפני <application>, הוסיפו:

<uses-permission android:name="android.permission.NFC" />
<uses-feature android:name="android.hardware.nfc" android:required="false" />

NFC היא הרשאת Manifest, לא חלון runtime כמו מיקום. required="false" מאפשר להתקין את המעבדה גם על אמולטור ולתרגל פענוח; בזמן ריצה חובה לבדוק אם באמת יש קורא.

ב־app > res > layout > activity_main.xml השאירו את ConstraintLayout id=main וה־window insets. החליפו Hello World! ב־LinearLayout אנכי constrained ל־top/start/end, עם הסבר, כפתור id=sample ו־TextView id=status עם live region. ב־app > res > values > strings.xml הוסיפו הודעות עבור no reader, NFC off, ready, not NDEF, read failed, unexpected content ו־station. הקוד המשלים בהמשך מציג את כל ה־strings ושינויי ה־XML.

2. Reader Mode שייך למסך הפעיל

ב־MainActivity, ב־onCreate, קבלו NfcAdapter.getDefaultAdapter(this). ב־onResume, אם הוא חסר או כבוי הציגו מצב; אחרת הפעילו Reader Mode לארבע משפחות תג נפוצות:

int session = readerGeneration;
nfc.enableReaderMode(this, tag -> readTag(tag, session),
        NfcAdapter.FLAG_READER_NFC_A | NfcAdapter.FLAG_READER_NFC_B
                | NfcAdapter.FLAG_READER_NFC_F | NfcAdapter.FLAG_READER_NFC_V,
        null);
readerEnabled = true;

ב־onPause, אם readerEnabled, קראו nfc.disableReaderMode(this) ואפסו את הדגל. כך אין קריאה כשהמסך איננו פעיל. אין להשתמש ב־FLAG_READER_SKIP_NDEF_CHECK כאן: לפי תיעוד NfcAdapter, הדגל מדלג על זיהוי NDEF ולכן Ndef.get(tag) עשוי לא לעבוד. Reader Mode הוא בחירה מתאימה למסך שקורא תג בזמן שהוא גלוי.

3. קוראים את התג בשרשור הקורא

readTag(Tag, int expected) מתקבלת מ־ReaderCallback. אם Ndef.get(tag) == null, מציגים nfc_not_ndef. בכניסה דוחים session ישן עם if (expected != readerGeneration) return. הדור expected נלכד בעת רישום Reader Mode, ולכן גם callback שמתחילה מאוחר עדיין שייכת למפגש המקורי. אחר כך:

try {
    ndef.connect();
    NdefMessage message = ndef.getNdefMessage();
    // Radio I/O stays on the reader thread; Views are updated only on main.
    runOnUiThread(() -> {
        if (expected == readerGeneration && !isDestroyed()) showMessage(message);
    });
} catch (IOException | FormatException | SecurityException error) {
    runOnUiThread(() -> {
        if (expected == readerGeneration && !isDestroyed())
            binding.status.setText(R.string.nfc_read_failed);
    });
} finally {
    try { ndef.close(); } catch (IOException ignored) { }
}

getNdefMessage() מבצעת I/O רדיו חוסם, ולכן אינה נקראת מן ה־UI thread. תיעוד Ndef מציין שגם null אפשרי; תג שיוצא מן השדה יכול לגרום ל־TagLostException (תת־סוג של IOException). close() חייב להתבצע גם אחרי כשל. עדכון Views נעשה ב־runOnUiThread.

ביטול Reader Mode מונע קריאות חדשות, אך קריאה שכבר התחילה עשויה עדיין לסיים. לכן readerGeneration מתקדמת בתחילת session וב־onPause; ה־lambda של Reader Mode לוכדת את הדור ברישום ומעבירה אותו ל־readTag. כל הודעה שמועברת ל־main בודקת שזה עדיין אותו session. כאן השדה הוא volatile, כי שרשור הרדיו קורא אותו וה־main משנה אותו. הבדיקה מונעת תוצאה מאוחרת לעדכן מסך שכבר עזבנו; finally עדיין סוגרת את החיבור.

4. מאשרים רק פורמט של תחנת כיתה

צרו קובץ חדש StationRecord.java ב־app > kotlin+java > com.example.topics. השיטה read(NdefMessage) בקוד המשלים דורשת רשומה אחת בלבד, TNF_WELL_KNOWN וסוג RTD_TEXT, בודקת שה־payload הוא UTF-8, מדלגת על קוד השפה על פי בית הסטטוס, ואז מאשרת רק תבנית LAB-[A-Z0-9-]{1,24}. בשאר המצבים היא מחזירה null. כך URI על תג או טקסט כמו OPEN-DOOR אינו הופך לפקודה.

לפי NdefRecord, רשומת Text שונה מרשומת URI, ו־createTextRecord("en", "LAB-ROOM-3") יוצרת הודעת דוגמה. כפתור sample בונה NdefMessage עם אותה רשומה וקורא showMessage כמו מסלול הרדיו; הוא בודק את הפענוח וה־UI, לא את האנטנה.

הקוד המשלים במלואו

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

MainActivity.java

פתיחת המקור ישירות — בקובץ Markdown המקורי, הקוד נמצא ב־code/23/MainActivity.java.md ביחס לשיעור.

הקוד המלא והשינויים עבור MainActivity.java

ב־MainActivity.java החילו את שינויי האזורים הבאים. שורות + נוספות, שורות - מוסרות, שורות ההקשר נשארות; ⁞ מסמן אזור קיים שלא הוצג. שימרו את שאר קוד התבנית.

 package com.example.topics;
 
+import android.nfc.FormatException;
+import android.nfc.NdefMessage;
+import android.nfc.NdefRecord;
+import android.nfc.NfcAdapter;
+import android.nfc.Tag;
+import android.nfc.tech.Ndef;
 import android.os.Bundle;
-
 import androidx.activity.EdgeToEdge;
 import androidx.appcompat.app.AppCompatActivity;
 import androidx.core.graphics.Insets;
 import androidx.core.view.ViewCompat;
 import androidx.core.view.WindowInsetsCompat;
-
 import com.example.topics.databinding.ActivityMainBinding;
+import java.io.IOException;
 
 public class MainActivity extends AppCompatActivity {
+    private ActivityMainBinding binding;
+    private NfcAdapter nfc;
+    private boolean readerEnabled;
+    private volatile int readerGeneration;
 
-    private ActivityMainBinding binding;
-
+    /**
+     * Creates the current Activity View tree and connects the screen's actions.
+     *
+     * @param savedInstanceState prior small UI snapshot, or null for a fresh launch
+     */
     @Override
     protected void onCreate(Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
 ⁞
         setContentView(binding.getRoot());
         ViewCompat.setOnApplyWindowInsetsListener(binding.main, (v, insets) -> {
             Insets bars = insets.getInsets(WindowInsetsCompat.Type.systemBars() | WindowInsetsCompat.Type.displayCutout());
-            v.setPadding(bars.left, bars.top, bars.right, bars.bottom);            return insets;
+            v.setPadding(bars.left, bars.top, bars.right, bars.bottom);
+            return insets;
+        });
+        nfc = NfcAdapter.getDefaultAdapter(this);
+        binding.sample.setOnClickListener(v -> {
+            NdefRecord record = NdefRecord.createTextRecord("en", "LAB-ROOM-3");
+            showMessage(new NdefMessage(new NdefRecord[]{record}));
         });
     }
+
+    /**
+     * Starts a new reader session only if NFC hardware is present and enabled.
+     */
+    @Override
+    protected void onResume() {
+        super.onResume();
+        readerGeneration++;
+        if (nfc == null) {
+            binding.status.setText(R.string.nfc_missing);
+        } else if (!nfc.isEnabled()) {
+            binding.status.setText(R.string.nfc_off);
+        } else {
+            binding.status.setText(R.string.nfc_ready);
+            int session = readerGeneration;
+            nfc.enableReaderMode(this, tag -> readTag(tag, session),
+                    NfcAdapter.FLAG_READER_NFC_A | NfcAdapter.FLAG_READER_NFC_B
+                            | NfcAdapter.FLAG_READER_NFC_F | NfcAdapter.FLAG_READER_NFC_V,
+                    null);
+            readerEnabled = true;
+        }
+    }
+
+    /**
+     * Invalidates pending results and disables this screen's reader session.
+     */
+    @Override
+    protected void onPause() {
+        readerGeneration++; // Invalidate radio results before disabling new reads.
+        if (readerEnabled) {
+            nfc.disableReaderMode(this);
+            readerEnabled = false;
+        }
+        super.onPause();
+    }
+
+    /**
+     * Reads NDEF on the reader thread and posts only a current-session result to main.
+     * The connection closes after success and after failures.
+     *
+     * @param tag tag detected by Reader Mode
+     * @param expected generation captured when this reader session was registered
+     */
+    private void readTag(Tag tag, int expected) {
+        if (expected != readerGeneration) return;
+        Ndef ndef = Ndef.get(tag);
+        if (ndef == null) {
+            runOnUiThread(() -> {
+                if (expected == readerGeneration && !isDestroyed())
+                    binding.status.setText(R.string.nfc_not_ndef);
+            });
+            return;
+        }
+        try {
+            ndef.connect();
+            NdefMessage message = ndef.getNdefMessage(); // Blocking radio I/O, not the UI thread.
+            runOnUiThread(() -> {
+                if (expected == readerGeneration && !isDestroyed()) showMessage(message);
+            });
+        } catch (IOException | FormatException | SecurityException error) {
+            runOnUiThread(() -> {
+                if (expected == readerGeneration && !isDestroyed())
+                    binding.status.setText(R.string.nfc_read_failed);
+            });
+        } finally {
+            try { ndef.close(); } catch (IOException ignored) { }
+        }
+    }
+
+    /**
+     * Displays only a station accepted by the shared parser.
+     *
+     * @param message possibly null NDEF message from hardware or the sample
+     */
+    private void showMessage(NdefMessage message) {
+        String station = StationRecord.read(message);
+        if (station == null) {
+            binding.status.setText(R.string.nfc_unexpected);
+        } else {
+            binding.status.setText(getString(R.string.nfc_station, station));
+        }
+    }
 }

StationRecord.java

פתיחת המקור ישירות — בקובץ Markdown המקורי, הקוד נמצא ב־code/23/StationRecord.java.md ביחס לשיעור.

הקוד המלא והשינויים עבור StationRecord.java

צרו StationRecord.java במיקום המתואר בשיעור. זהו קובץ חדש, ולכן הקוד מוצג במלואו.

package com.example.topics;

import android.nfc.NdefMessage;
import android.nfc.NdefRecord;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;

/** Parses only a short classroom station ID, never an arbitrary URI or command. */
public final class StationRecord {
    /**
     * Prevents instances of this stateless parser utility.
     */
    private StationRecord() { }

    /**
     * Accepts exactly one UTF-8 Text record with the classroom station format.
     * An accepted ID is data, not proof of identity or permission.
     *
     * @param message possibly null message to validate
     * @return station ID, or null for an unsupported or malformed record
     */
    public static String read(NdefMessage message) {
        if (message == null || message.getRecords().length != 1) return null;
        NdefRecord record = message.getRecords()[0];
        if (record.getTnf() != NdefRecord.TNF_WELL_KNOWN
                || !Arrays.equals(record.getType(), NdefRecord.RTD_TEXT)) return null;
        byte[] payload = record.getPayload();
        if (payload.length < 2 || (payload[0] & 0x80) != 0) return null; // UTF-8 text only.
        // The lower six status bits specify language bytes, not station characters.
        int languageLength = payload[0] & 0x3f;
        if (payload.length <= 1 + languageLength) return null;
        String station = new String(payload, 1 + languageLength,
                payload.length - 1 - languageLength, StandardCharsets.UTF_8);
        return station.matches("LAB-[A-Z0-9-]{1,24}") ? station : null;
    }
}

StationRecordTest.java

פתיחת המקור ישירות — בקובץ Markdown המקורי, הקוד נמצא ב־code/23/StationRecordTest.java.md ביחס לשיעור.

הקוד המלא והשינויים עבור StationRecordTest.java

צרו StationRecordTest.java במיקום המתואר בשיעור. זהו קובץ חדש, ולכן הקוד מוצג במלואו.

package com.example.topics;

import android.nfc.NdefMessage;
import android.nfc.NdefRecord;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.Test;
import org.junit.runner.RunWith;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;

@RunWith(AndroidJUnit4.class)
public class StationRecordTest {
    /**
     * Checks that an NDEF Text record reaches the station parser successfully.
     */
    @Test
    public void acceptsStationText() {
        assertEquals("LAB-ROOM-3", StationRecord.read(new NdefMessage(new NdefRecord[]{
                NdefRecord.createTextRecord("en", "LAB-ROOM-3")})));
    }

    /**
     * Checks rejection of different record types and text outside the station format.
     */
    @Test
    public void refusesArbitraryUriAndText() {
        assertNull(StationRecord.read(new NdefMessage(new NdefRecord[]{
                NdefRecord.createUri("https://example.com")})));
        assertNull(StationRecord.read(new NdefMessage(new NdefRecord[]{
                NdefRecord.createTextRecord("en", "OPEN-DOOR")})));
    }
}

activity_main.xml

פתיחת המקור ישירות — בקובץ Markdown המקורי, הקוד נמצא ב־code/23/activity_main.xml.md ביחס לשיעור.

הקוד המלא והשינויים עבור activity_main.xml

ב־activity_main.xml החילו את שינויי האזורים הבאים. שורות + נוספות, שורות - מוסרות, שורות ההקשר נשארות; ⁞ מסמן אזור קיים שלא הוצג. שימרו את שאר קוד התבנית.

     android:layout_width="match_parent"
     android:layout_height="match_parent"
     tools:context=".MainActivity">
-
-    <TextView
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:text="Hello World!"
-        app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintEnd_toEndOf="parent"
+    <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content"
+        android:orientation="vertical" android:padding="24dp"
+        app:layout_constraintTop_toTopOf="parent"
         app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toTopOf="parent" />
-
+        app:layout_constraintEnd_toEndOf="parent">
+        <TextView android:layout_width="match_parent" android:layout_height="wrap_content"
+            android:text="@string/nfc_intro" android:textSize="20sp" />
+        <Button android:id="@+id/sample" android:layout_width="match_parent"
+            android:layout_height="wrap_content" android:text="@string/nfc_sample" />
+        <TextView android:id="@+id/status" android:layout_width="match_parent"
+            android:layout_height="wrap_content" android:layout_marginTop="16dp"
+            android:accessibilityLiveRegion="polite" android:text="@string/nfc_wait" />
+    </LinearLayout>
 </androidx.constraintlayout.widget.ConstraintLayout>

strings.xml

פתיחת המקור ישירות — בקובץ Markdown המקורי, הקוד נמצא ב־code/23/strings.xml.md ביחס לשיעור.

הקוד המלא והשינויים עבור strings.xml

ב־strings.xml החילו את שינויי האזורים הבאים. שורות + נוספות, שורות - מוסרות, שורות ההקשר נשארות; ⁞ מסמן אזור קיים שלא הוצג. שימרו את שאר קוד התבנית.

 <resources>
     <string name="app_name">topics</string>
+    <string name="nfc_intro">Tap a classroom NDEF text tag containing a station ID such as LAB-ROOM-3.</string>
+    <string name="nfc_sample">Try a sample record</string>
+    <string name="nfc_wait">Checking NFC…</string>
+    <string name="nfc_missing">This device has no NFC reader. The sample still works.</string>
+    <string name="nfc_off">NFC is turned off. Enable it in device settings.</string>
+    <string name="nfc_ready">NFC ready. Hold a classroom tag near the phone.</string>
+    <string name="nfc_not_ndef">Tag found, but it is not an NDEF tag.</string>
+    <string name="nfc_read_failed">Tag left the field or could not be read. Tap again.</string>
+    <string name="nfc_unexpected">NDEF read, but it is not a valid classroom station ID.</string>
+    <string name="nfc_station">Classroom station: %1$s</string>
 </resources>

בדיקות וגבול הראיה

  1. :app:assembleDebug ו־:app:connectedDebugAndroidTest עברו באמולטור. StationRecordTest מאשרת מזהה תקין ודוחה URI וטקסט שאינו לפי התבנית.
  2. באמולטור התקבלה ההודעה This device has no NFC reader. כפתור הדוגמה הציג Classroom station: LAB-ROOM-3. זה מוכיח שמסך הכשל עובד ושה־parser מקבל דוגמה תקינה.
  3. על טלפון עם NFC כתבו תג NDEF Text עם LAB-ROOM-3, פתחו את האפליקציה וקרבו את התג. בדקו גם NFC כבוי, תג לא־NDEF, הוצאה מהירה מן השדה ורשומה לא צפויה. בדיקת חומרה זו עדיין לא בוצעה בענף הדוגמה.
  4. אם הפרויקט דורש שידור דו־כיווני או חיבור מתמשך לחיישן, NFC Text עשוי להיות כלי לא נכון; בדקו מסלול Bluetooth עם הרשאות, pairing, states ו־timeouts מתאימים. אל תוסיפו את שתי הטכנולוגיות רק בשביל רשימת נושאים.