Skip to content

Oura: a ring with no advertised name lists with a blank label on both platforms #1783

Closed

Description

@ryanbr

Surfaced while reviewing #1776, which is merged and correct — this is the gap it makes reachable, not a defect in it.

Now that a bonded Oura ring advertising no local name is listed, name is "" all the way to the UI, and neither platform guards it.

Android

AddDeviceWizard.kt:1339 passes the raw name into the picker row:

DiscoveredRow(
    name = ring.name,
    subtitle = ring.detectedGen?.displayName ?: "Oura ring",

and DiscoveredRow renders it unguarded at :1641:

Text(name, style = NoopType.body, color = Palette.textPrimary)

So the row draws an empty primary line above the "Oura ring" subtitle. It is still tappable and the subtitle identifies it, which is why this is not a blocker — but an empty title reads as a broken row rather than as "this ring has no name".

Apple

AddDeviceWizard.swift:1160 has the same gap in the confirm step's name draft:

if let pickedOura { return pickedOura.ring.name }

This one is pre-existing — Apple has always listed nameless rings — so an empty name draft has been reachable there for as long as the feature has existed.

The fix is already written, four lines up

:1156 does exactly the right thing for WHOOP:

if let pickedWhoop { return pickedWhoop.name.isEmpty ? (type.map(typeTitle) ?? String(localized: "Device")) : pickedWhoop.name }

So the pattern to copy is in the same function. The Oura equivalent wants the same shape — fall back to a generic ring label when the advertised name is blank — on both platforms, and on Android in DiscoveredRow's caller rather than in the row itself, so the row stays a dumb renderer.

Scope

  • New user-visible copy on both platforms, so it needs the Android l10n_ key and all eight iOS locales in the xcstrings catalogue.
  • A detectedGen of null already falls back to GEN3 in the wizard, so no further wiring is needed to actually add the ring — this is purely how it is labelled.
  • Worth a JVM test on the Android side pinning that a blank advertised name produces a non-blank row title, since that is assertable without a radio even though discovery itself is not.

Thanks @pipiche38 — the nameless-ring case only became visible because the discovery bug was fixed first.

Activity

  1. kvnloo commented on Sep 1, 2026

    @kvnloo

    Happy to take this. Plan matches the issue:

    Apple confirmAdvertisedName — copy the WHOOP empty-name fallback already four lines up:

    if let pickedOura {
        return pickedOura.ring.name.isEmpty
            ? (type.map(typeTitle) ?? String(localized: "Device"))
            : pickedOura.ring.name
    }
    

    Android DiscoveredRow caller — fall back before the row so the row stays a dumb renderer:

    name = ring.name.ifBlank {
        uiString(R.string.l10n_…_oura_ring_fallback) // new key; keep subtitle as detectedGen / "Oura ring"
    }
    

    Scope: new user-visible copy on both platforms (Android l10n_ key + all eight iOS xcstrings locales). A detectedGen of null already falls back to GEN3 in the wizard, so no further adopt wiring. JVM test on the Android name helper if a cheap pure function is extractable; otherwise the Apple change is the pattern source of truth.

    Abort if someone else is already on it.

  2. pipiche38 commented on Sep 1, 2026

    @pipiche38

    @kvnloo — flagging before you spend time on it, since your plan follows the issue as written: the
    guard you'd be adding is already there one layer down. The parity and i18n bits above are what's
    actually left, and they're small.

    Checked this before writing the guard, and the premise doesn't hold — on either platform.

    DiscoveredRing.name is never blank. Both sides already substitute a fallback at the single
    construction site, and have since the original Oura commit (4897f26e), not since #1776:

    // Strand/BLE/OuraLiveSource.swift
    name: name.isEmpty ? "Oura" : name,
    // android/…/ble/OuraLiveSource.kt
    name = name.ifBlank { "Oura" },

    Those are the only two places a DiscoveredRing is constructed on either platform, so every call
    site listed in the issue — AddDeviceWizard.kt:1339, AddDeviceWizard.swift:1160, and
    AddDeviceWizard.swift:1646, which has the same shape and isn't listed — receives an
    already-guarded value. A nameless bonded ring lists as "Oura" over its subtitle, not as an
    empty line. Adding the .isEmpty fallback at those call sites would be dead code, and the JVM test
    pinning it would pin nothing.

    There is something real in that code, though, just not the reported symptom.

    The two guards disagree. ifBlank treats an all-whitespace name as absent; isEmpty does not.
    A ring advertising " " lists as "Oura" on Android and blank on Apple. That's the silent
    Swift/Kotlin drift the parity contract warns about — both lines read correctly on their own, and
    only the pair is wrong.

    I've folded both into one named helper, ouraAdvertisedLabel, whitespace-aware on both sides and
    returning the name untrimmed when present, pinned by an oracle test whose expected values are the
    Swift twin's own stdout rather than a reading of the Kotlin. That caught a bug in my own first
    draft within minutes: I'd written advertisedName.trim().ifEmpty { fallback }, which returns the
    trimmed name where Swift returns the original. Side-by-side review would not have caught it; the
    " Oura Ring 4 " case does.

    One genuine i18n bug in the same expression. The Android subtitle fallback was a hardcoded
    English literal:

    subtitle = ring.detectedGen?.displayName ?: "Oura ring",

    while l10n_add_device_wizard_oura_ring_e3431536 already exists in all six locales. That fallback
    renders exactly when the generation is undetected — which is the nameless bonded-ring case from
    #1776 — so a non-English user hit untranslated copy on the one row this issue is about. Fixed with
    the existing key, so no new string on either platform.

    Verified: Android 5040 unit tests (4/4 in OuraNamelessRingDiscoveryTest, including the new oracle
    case); macOS xcodebuild -scheme Strand succeeds, since app-target Swift isn't covered by the
    default CI jobs; doc_comment_lint and i18n_audit --ci both clean. No hardware run — this
    changes a label, not the BLE path, and discovery behaviour is untouched.

    Proposing: #1797

  3. ryanbr commented on Sep 2, 2026

    @ryanbr
    OwnerAuthor

    Closing. The reported symptom was never real — my error, and @pipiche38 caught it before anyone
    built the wrong thing.

    The premise was wrong

    This issue asserted that name is "" all the way to the UI and that "neither platform guards
    it". Both guards were already there, one layer down at the single DiscoveredRing construction
    site, and had been since the original Oura commit:

    4897f26ef  2026-06-29  Add local Oura ring support (Gen 3/4/5), on-device, beta
    7b566f578  2026-09-01  fix(oura): list a bonded ring that advertises no local name (#1776)
    

    git log -S on both fallback expressions returns 4897f26ef — two months before #1776, so this
    was not a gap #1776 made reachable. A nameless bonded ring has always listed as "Oura" over its
    subtitle, never as an empty line. The guard at AddDeviceWizard.kt:1339 and
    AddDeviceWizard.swift:1160 that this issue asked for would have been dead code, and the JVM test
    it asked for would have pinned nothing.

    How I got there is worth recording: I read the two render sites, saw no guard, and reported the
    absence of a guard anywhere as a fact — without checking the construction site one layer down.
    Same shape as the sweep failure in #1787: looking where the bug was expected and treating a
    negative result there as a global negative.

    What was actually real, and where it landed

    The investigation was not wasted — it surfaced two genuine defects, neither of them the reported
    one, both now on main via #1797:

    • A silent Swift/Kotlin divergence. The two guards disagreed: ifBlank treats an
      all-whitespace name as absent, isEmpty does not, so a ring advertising " " listed as "Oura"
      on Android and blank on Apple. Both lines read correctly alone; only the pair was wrong. Now one
      helper, ouraAdvertisedLabel, on both platforms, pinned by an oracle test whose expectations are
      the Swift twin's own stdout — which caught a trim()-based draft that returned the trimmed name
      where Swift returns the original. " Oura Ring 4 " is the separating case.
    • A real i18n bug in the same expression. The Android subtitle fallback was a hardcoded
      English "Oura ring" while l10n_add_device_wizard_oura_ring_e3431536 already existed in all
      seven locale files — and it renders exactly when the generation is undetected, which is the
      nameless bonded-ring case. Now uses the existing key, matching the localisation Apple already
      had. No new string on either platform.

    One follow-up of mine on top (206a6fff9): the helper's doc claimed the two stdlibs part on
    U+00A0. They do not — ifBlank tests Kotlin's Char.isWhitespace, which is
    Character.isWhitespace(c) || Character.isSpaceChar(c), and the second disjunct covers exactly the
    non-breaking spaces the first drops. The real divergence is U+0085 and U+001C–U+001F.

    Thanks @pipiche38 for checking the premise instead of implementing it, and @kvnloo for picking it
    up — the flag came before you'd spent the time, which is the system working.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions