diff --git a/CHANGELOG.md b/CHANGELOG.md index babc4b926e..77b1605492 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ # Changelog ## Unreleased +- **Xbox sign-in with an account that has no Xbox profile (test266, Saleh: "you can't sign out to start fresh"):** the sign-in kept the Microsoft account even when Xbox network refused it, so it ended "Signed in" with no gamertag, and signing in again went straight back to the same account because the phone's browser was still signed in to it. Now: + - **Handheld:** "Open the page" opens Microsoft's page inside MoonCast, with the code already filled in and no Microsoft account remembered (a fresh start every time). It closes by itself once you're signed in; Close goes back to the code. Sign out forgets that page's account too. + - Right after the sign-in MoonCast asks Xbox network about the account. If it has no Xbox profile, is a child account not yet in a family, or can't use Xbox network in its country, nothing is kept and the card says so in plain words ("This Microsoft account has no Xbox profile. Sign in with the account you use on your Xbox."). If Xbox network can't be reached, the sign-in is kept and it says it couldn't check. + - Someone already in that state sees Settings, Xbox: "Signed in, no Xbox profile", and Sign out is picked in its question. Same words on the TV, which gets the same check (its sign-in is shared with the handheld). + - **TV code card:** next to the big code there's now a QR code (made on the TV, no QR service) that a phone camera opens as microsoft.com/link with the code already filled in, and a line: "To use a different Microsoft account, open the page in a private browser window." (the TV can't clear another device's browser). - **Xbox closed as soon as it connected (Odin, beta 3, Saleh):** libwebrtc hands MoonCast's low-latency H.264 decoder wrapper a null `DecodeInfo` with a frame; the Kotlin override declared it non-null, so the first frame threw (`NullPointerException … LowLatencyDecoder.decode, parameter info`) and the player closed. The parameter is nullable now and is passed on as is. ## 1.0-beta3 (2026-10-24) diff --git a/app/src/handheld/java/com/limelight/handheld/HandheldHomeActivity.kt b/app/src/handheld/java/com/limelight/handheld/HandheldHomeActivity.kt index 9638297c5c..acae3aee50 100644 --- a/app/src/handheld/java/com/limelight/handheld/HandheldHomeActivity.kt +++ b/app/src/handheld/java/com/limelight/handheld/HandheldHomeActivity.kt @@ -1664,7 +1664,10 @@ class HandheldHomeActivity : Activity() { info(list, "Touchpad", "Select + Start") } "xbox" -> { - if (Xbox.signedIn(this)) action(list, "xbox", "Xbox network", "Signed in" + (Xbox.gamertag(this)?.let { " as $it" } ?: ""), "Sign out") { + if (Xbox.noProfile(this)) action(list, "xbox", "Xbox network", "Signed in, no Xbox profile. Sign out, then sign in with the account you use on your Xbox", "Sign out") { + confirm("Sign out of Xbox?", "This Microsoft account has no Xbox profile. Sign out to start fresh with another account.", "Sign out", yesPicked = true) { + Xbox.signOut(this); xboxes = emptyList(); render(force = true) } } + else if (Xbox.signedIn(this)) action(list, "xbox", "Xbox network", "Signed in" + (Xbox.gamertag(this)?.let { " as $it" } ?: ""), "Sign out") { confirm("Sign out of Xbox?", "Your Xbox consoles leave this device until you sign in again.", "Sign out") { Xbox.signOut(this); xboxes = emptyList(); render(force = true) } } else action(list, "xbox", "Xbox network", "Sign in once to play your Xbox here, at home or away", "Sign in") { xboxSignIn() } @@ -2249,8 +2252,10 @@ class HandheldHomeActivity : Activity() { val code = sw.text("", 80, sw.ink, 800).apply { letterSpacing = 0.12f; gravity = Gravity.CENTER } card.addView(code, sw.lp(-1, -2).apply { topMargin = sw.px(26); bottomMargin = sw.px(10) }) var url: String? = null - buttons(card, listOf("Cancel" to {}, "Open the page" to { - url?.let { u -> try { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(u))) } catch (e: Exception) { toast("Open $u in a browser") } } + // the page opens inside MoonCast with no remembered Microsoft account, the code already filled in + buttons(card, listOf("Cancel" to { com.limelight.home.XboxSignInActivity.close() }, "Open the page" to { + url?.let { u -> startActivity(Intent(this, com.limelight.home.XboxSignInActivity::class.java) + .putExtra(com.limelight.home.XboxSignInActivity.EXTRA_URL, u)) } Unit }), keepOpenLast = true) showOverlay("xbox", card) @@ -2258,8 +2263,8 @@ class HandheldHomeActivity : Activity() { try { val dc = Xbox.startSignIn() runOnUiThread { - url = dc.url - sub.text = "Open ${dc.url.removePrefix("https://").removePrefix("www.")} (here or on another device), enter this code, and sign in with your Xbox account." + url = Xbox.linkUrl(dc) + sub.text = "Open the page (here, or ${dc.url.removePrefix("https://").removePrefix("www.")} on another device), enter this code, and sign in with the account you use on your Xbox." code.text = dc.userCode } val end = System.currentTimeMillis() + dc.expiresIn * 1000L @@ -2267,11 +2272,13 @@ class HandheldHomeActivity : Activity() { Thread.sleep(dc.interval * 1000L) if (overlayKind != "xbox") return@Thread if (Xbox.pollSignIn(this, dc)) { + com.limelight.home.XboxSignInActivity.close() val list = try { Xbox.refreshConsoles(this) } catch (e: Exception) { emptyList() } runOnUiThread { xboxes = list if (overlayKind == "xbox") hideOverlay() - toast("Signed in to Xbox" + (Xbox.gamertag(this)?.let { " as $it" } ?: "") + + toast(if (Xbox.unverified(this)) "Signed in, but Xbox network couldn't be reached to check the account. Try Find my Xbox in a moment" + else "Signed in to Xbox" + (Xbox.gamertag(this)?.let { " as $it" } ?: "") + if (list.isEmpty()) ". No console found yet" else ". Found " + list.joinToString { it.name }) render(force = true) } @@ -2279,6 +2286,13 @@ class HandheldHomeActivity : Activity() { } } runOnUiThread { if (overlayKind == "xbox") { sub.text = "The code expired. Cancel and try again."; code.text = "" } } + } catch (e: Xbox.ProfileError) { + // the account can't use Xbox: nothing is kept, and the next try starts with no account remembered + com.limelight.home.XboxSignInActivity.close() + runOnUiThread { + if (overlayKind == "xbox") { sub.text = e.message; code.text = "" } + render(force = true) + } } catch (e: Exception) { runOnUiThread { if (overlayKind == "xbox") sub.text = "Sign-in didn't work: ${e.message?.take(120) ?: e.javaClass.simpleName}" } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e3f8538db5..ae8b640803 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -104,6 +104,12 @@ android:theme="@style/StreamTheme" android:windowSoftInputMode="adjustResize" android:configChanges="orientation|screenSize|keyboardHidden|keyboard|navigation|uiMode|density|smallestScreenSize|screenLayout" /> + + ? { + val data = text.toByteArray(Charsets.UTF_8) + var ver = 1 + while (ver <= 10) { + val bits = 4 + (if (ver < 10) 8 else 16) + data.size * 8 + if (bits <= dataCodewords(ver) * 8) break + ver++ + } + if (ver > 10) return null + // data bits: byte mode, length, bytes, terminator, padding + val bb = ArrayList() + fun put(v: Int, n: Int) { for (i in n - 1 downTo 0) bb.add((v ushr i) and 1) } + put(4, 4); put(data.size, if (ver < 10) 8 else 16) + data.forEach { put(it.toInt() and 0xFF, 8) } + val cap = dataCodewords(ver) * 8 + put(0, minOf(4, cap - bb.size)) + put(0, (8 - bb.size % 8) % 8) + var pad = 0xEC + while (bb.size < cap) { put(pad, 8); pad = pad xor (0xEC xor 0x11) } + val codewords = ByteArray(bb.size / 8) + for (i in bb.indices) codewords[i ushr 3] = (codewords[i ushr 3].toInt() or (bb[i] shl (7 - (i and 7)))).toByte() + + val q = Grid(ver) + q.functionPatterns() + q.drawCodewords(addEcc(codewords, ver)) + var best = 0; var bestPenalty = Int.MAX_VALUE + for (m in 0 until 8) { + q.applyMask(m); q.drawFormat(m) + val p = q.penalty() + if (p < bestPenalty) { best = m; bestPenalty = p } + q.applyMask(m) // XOR again undoes it + } + q.applyMask(best); q.drawFormat(best) + return q.modules + } + + private fun rawModules(ver: Int): Int { + var r = (16 * ver + 128) * ver + 64 + if (ver >= 2) { + val n = ver / 7 + 2 + r -= (25 * n - 10) * n - 55 + if (ver >= 7) r -= 36 + } + return r + } + + private fun dataCodewords(ver: Int) = rawModules(ver) / 8 - ECC_PER_BLOCK[ver - 1] * NUM_BLOCKS[ver - 1] + + private fun addEcc(data: ByteArray, ver: Int): ByteArray { + val numBlocks = NUM_BLOCKS[ver - 1]; val eccLen = ECC_PER_BLOCK[ver - 1] + val raw = rawModules(ver) / 8 + val numShort = numBlocks - raw % numBlocks + val shortLen = raw / numBlocks + val div = rsDivisor(eccLen) + val blocks = ArrayList() + var k = 0 + for (i in 0 until numBlocks) { + val datLen = shortLen - eccLen + (if (i < numShort) 0 else 1) + val dat = data.copyOfRange(k, k + datLen); k += datLen + val ecc = rsRemainder(dat, div) + val block = ByteArray(shortLen + 1) + // short blocks get a gap at datLen so all blocks line up + System.arraycopy(dat, 0, block, 0, dat.size) + System.arraycopy(ecc, 0, block, shortLen + 1 - eccLen, eccLen) + blocks.add(block) + } + val out = ByteArray(raw); var n = 0 + for (i in 0..shortLen) for (j in blocks.indices) { + if (i != shortLen - eccLen || j >= numShort) out[n++] = blocks[j][i] + } + return out + } + + private fun mul(x: Int, y: Int): Int { + var z = 0 + for (i in 7 downTo 0) { z = (z shl 1) xor ((z ushr 7) * 0x11D); z = z xor (((y ushr i) and 1) * x) } + return z + } + + private fun rsDivisor(degree: Int): IntArray { + val r = IntArray(degree); r[degree - 1] = 1 + var root = 1 + for (i in 0 until degree) { + for (j in r.indices) { r[j] = mul(r[j], root); if (j + 1 < r.size) r[j] = r[j] xor r[j + 1] } + root = mul(root, 0x02) + } + return r + } + + private fun rsRemainder(data: ByteArray, div: IntArray): ByteArray { + val r = IntArray(div.size) + for (b in data) { + val f = (b.toInt() and 0xFF) xor r[0] + System.arraycopy(r, 1, r, 0, r.size - 1); r[r.size - 1] = 0 + for (i in r.indices) r[i] = r[i] xor mul(div[i], f) + } + return ByteArray(r.size) { r[it].toByte() } + } + + private class Grid(val ver: Int) { + val size = ver * 4 + 17 + val modules = Array(size) { BooleanArray(size) } + val isFunction = Array(size) { BooleanArray(size) } + + fun set(x: Int, y: Int, dark: Boolean) { modules[y][x] = dark; isFunction[y][x] = true } + + fun functionPatterns() { + for (i in 0 until size) { set(6, i, i % 2 == 0); set(i, 6, i % 2 == 0) } + finder(3, 3); finder(size - 4, 3); finder(3, size - 4) + val pos = alignmentPositions() + for (i in pos.indices) for (j in pos.indices) { + if (i == 0 && j == 0 || i == 0 && j == pos.size - 1 || i == pos.size - 1 && j == 0) continue + for (dy in -2..2) for (dx in -2..2) set(pos[i] + dx, pos[j] + dy, maxOf(Math.abs(dx), Math.abs(dy)) != 1) + } + drawFormat(0) + if (ver >= 7) { + var rem = ver + for (i in 0 until 12) rem = (rem shl 1) xor ((rem ushr 11) * 0x1F25) + val bits = (ver shl 12) or rem + for (i in 0 until 18) { + val bit = ((bits ushr i) and 1) != 0 + val a = size - 11 + i % 3; val b = i / 3 + set(a, b, bit); set(b, a, bit) + } + } + } + + private fun finder(x: Int, y: Int) { + for (dy in -4..4) for (dx in -4..4) { + val d = maxOf(Math.abs(dx), Math.abs(dy)); val xx = x + dx; val yy = y + dy + if (xx in 0 until size && yy in 0 until size) set(xx, yy, d != 2 && d != 4) + } + } + + private fun alignmentPositions(): IntArray { + if (ver == 1) return IntArray(0) + val num = ver / 7 + 2 + val step = (ver * 8 + num * 3 + 5) / (num * 4 - 4) * 2 + val r = IntArray(num); r[0] = 6 + var pos = size - 7 + for (i in num - 1 downTo 1) { r[i] = pos; pos -= step } + return r + } + + fun drawFormat(mask: Int) { + val data = (ECC_FORMAT_BITS shl 3) or mask + var rem = data + for (i in 0 until 10) rem = (rem shl 1) xor ((rem ushr 9) * 0x537) + val bits = ((data shl 10) or rem) xor 0x5412 + fun b(i: Int) = ((bits ushr i) and 1) != 0 + for (i in 0..5) set(8, i, b(i)) + set(8, 7, b(6)); set(8, 8, b(7)); set(7, 8, b(8)) + for (i in 9 until 15) set(14 - i, 8, b(i)) + for (i in 0 until 8) set(size - 1 - i, 8, b(i)) + for (i in 8 until 15) set(8, size - 15 + i, b(i)) + set(8, size - 8, true) + } + + fun drawCodewords(data: ByteArray) { + var i = 0 + var right = size - 1 + while (right >= 1) { + if (right == 6) right = 5 + for (vert in 0 until size) for (j in 0..1) { + val x = right - j + val upward = ((right + 1) and 2) == 0 + val y = if (upward) size - 1 - vert else vert + if (!isFunction[y][x] && i < data.size * 8) { + modules[y][x] = (((data[i ushr 3].toInt() and 0xFF) ushr (7 - (i and 7))) and 1) != 0 + i++ + } + } + right -= 2 + } + } + + fun applyMask(m: Int) { + for (y in 0 until size) for (x in 0 until size) { + val inv = when (m) { + 0 -> (x + y) % 2 == 0 + 1 -> y % 2 == 0 + 2 -> x % 3 == 0 + 3 -> (x + y) % 3 == 0 + 4 -> (x / 3 + y / 2) % 2 == 0 + 5 -> x * y % 2 + x * y % 3 == 0 + 6 -> (x * y % 2 + x * y % 3) % 2 == 0 + else -> ((x + y) % 2 + x * y % 3) % 2 == 0 + } + if (inv && !isFunction[y][x]) modules[y][x] = !modules[y][x] + } + } + + /** A simplified penalty (runs, 2x2 blocks, dark balance): enough to pick a mask that scans well. */ + fun penalty(): Int { + var p = 0 + for (y in 0 until size) { + var run = 1 + for (x in 1 until size) { if (modules[y][x] == modules[y][x - 1]) { run++; if (run == 5) p += 3 else if (run > 5) p++ } else run = 1 } + } + for (x in 0 until size) { + var run = 1 + for (y in 1 until size) { if (modules[y][x] == modules[y - 1][x]) { run++; if (run == 5) p += 3 else if (run > 5) p++ } else run = 1 } + } + for (y in 0 until size - 1) for (x in 0 until size - 1) { + val c = modules[y][x] + if (c == modules[y][x + 1] && c == modules[y + 1][x] && c == modules[y + 1][x + 1]) p += 3 + } + var dark = 0 + for (row in modules) for (c in row) if (c) dark++ + val total = size * size + p += (Math.abs(dark * 20 - total * 10) + total - 1) / total * 10 + return p + } + } + + /** A bitmap of [modules] with a 4-module quiet zone, [px] pixels per module, black on white. */ + @JvmStatic + fun bitmap(modules: Array, px: Int): android.graphics.Bitmap { + val n = modules.size + 8 + val bmp = android.graphics.Bitmap.createBitmap(n * px, n * px, android.graphics.Bitmap.Config.ARGB_8888) + bmp.eraseColor(0xFFFFFFFF.toInt()) + for (y in modules.indices) for (x in modules.indices) if (modules[y][x]) + for (dy in 0 until px) for (dx in 0 until px) bmp.setPixel((x + 4) * px + dx, (y + 4) * px + dy, 0xFF000000.toInt()) + return bmp + } +} diff --git a/app/src/main/java/com/limelight/home/Xbox.kt b/app/src/main/java/com/limelight/home/Xbox.kt index b5ec0e88ad..7269ae11f5 100644 --- a/app/src/main/java/com/limelight/home/Xbox.kt +++ b/app/src/main/java/com/limelight/home/Xbox.kt @@ -29,7 +29,26 @@ object Xbox { private fun prefs(ctx: Context) = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE) fun signedIn(ctx: Context) = prefs(ctx).getString("refresh", null) != null fun gamertag(ctx: Context): String? = prefs(ctx).getString("gamertag", null) - fun signOut(ctx: Context) = prefs(ctx).edit().clear().apply() + /** Signed in to Microsoft, but the account has no working Xbox profile (no gamertag came back). */ + fun noProfile(ctx: Context) = signedIn(ctx) && gamertag(ctx) == null && !unverified(ctx) + /** Also forgets the Microsoft account in MoonCast's sign-in page, so the next sign-in can pick another one. */ + fun signOut(ctx: Context) { + prefs(ctx).edit().clear().apply() + XboxSignInActivity.clearCookies() + } + + /** The Microsoft account works but can't use Xbox network (Xbox's XErr, in plain words). The tokens aren't kept. */ + class ProfileError(message: String) : Exception(message) + + private fun profileMessage(xerr: Long): String? = when (xerr) { + 2148916233L -> "This Microsoft account has no Xbox profile. Sign in with the account you use on your Xbox." + 2148916238L -> "This is a child account. An adult needs to add it to a Microsoft family before it can use Xbox network." + 2148916235L -> "Xbox network isn't available in this account's country or region." + 2148916236L, 2148916237L -> "This account needs adult verification on xbox.com before it can use Xbox network." + 2148916227L -> "This account is banned from Xbox network." + 2148916229L -> "Parental settings on this account don't allow Xbox network sign-in here." + else -> null + } // ---------------------------------------------------------------- HTTP @@ -70,7 +89,20 @@ object Xbox { val j = JSONObject(request(MS_TOKEN, "POST", form("grant_type" to "urn:ietf:params:oauth:grant-type:device_code", "client_id" to CLIENT_ID, "device_code" to dc.deviceCode), formHeaders)) saveUserToken(ctx, j) - try { webAuth(ctx) } catch (e: Exception) { } // fetches the gamertag + // Does this account have an Xbox profile? (It also fetches the gamertag.) An account without one used to end + // "Signed in" with no gamertag, and signing in again reused it (test266, Saleh). + try { + webAuth(ctx) + prefs(ctx).edit().remove("unverified").apply() + } catch (e: HttpError) { + val xerr = try { JSONObject(e.body).optLong("XErr", 0) } catch (x: Exception) { 0L } + val msg = profileMessage(xerr) + ?: if (e.code == 401 || e.code == 403) "This Microsoft account can't sign in to Xbox network (error ${if (xerr != 0L) xerr else e.code})." else null + if (msg != null) { signOut(ctx); throw ProfileError(msg) } + prefs(ctx).edit().putBoolean("unverified", true).apply() + } catch (e: Exception) { + prefs(ctx).edit().putBoolean("unverified", true).apply() // network: kept, checked again later + } true } catch (e: HttpError) { val err = try { JSONObject(e.body).optString("error") } catch (x: Exception) { "" } @@ -78,6 +110,12 @@ object Xbox { } } + /** Signed in but Xbox network couldn't be reached to check the profile (kept; checked again on the next use). */ + fun unverified(ctx: Context) = prefs(ctx).getBoolean("unverified", false) + + /** The device-code page with the code filled in (microsoft.com/link takes ?otc=). */ + fun linkUrl(dc: DeviceCode): String = "https://www.microsoft.com/link?otc=" + URLEncoder.encode(dc.userCode, "UTF-8") + private fun saveUserToken(ctx: Context, j: JSONObject) { prefs(ctx).edit().putString("access", j.getString("access_token")) .putString("refresh", j.optString("refresh_token", prefs(ctx).getString("refresh", ""))) diff --git a/app/src/main/java/com/limelight/home/XboxSignInActivity.kt b/app/src/main/java/com/limelight/home/XboxSignInActivity.kt new file mode 100644 index 0000000000..d92906e1bf --- /dev/null +++ b/app/src/main/java/com/limelight/home/XboxSignInActivity.kt @@ -0,0 +1,77 @@ +package com.limelight.home + +import android.annotation.SuppressLint +import android.app.Activity +import android.os.Bundle +import android.util.TypedValue +import android.view.Gravity +import android.webkit.CookieManager +import android.webkit.WebStorage +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.LinearLayout +import android.widget.TextView +import java.lang.ref.WeakReference + +/** + * Microsoft's device-code page (microsoft.com/link, the code filled in) inside MoonCast, for the handheld's Xbox + * sign-in. It starts with no cookies every time, so it never reuses a Microsoft account the phone's browser is signed + * in to: after an account without an Xbox profile, the next try can pick another one (test266, Saleh). The home's code + * card keeps polling meanwhile; once signed in it closes this page, and Close or Back returns to the card. + */ +class XboxSignInActivity : Activity() { + companion object { + const val EXTRA_URL = "url" + private var open: WeakReference? = null + + /** Signed in (or given up): close the page if it's open. */ + @JvmStatic fun close() { open?.get()?.let { a -> a.runOnUiThread { if (!a.isFinishing) a.finish() } } } + + /** Forget every Microsoft sign-in this app's web pages hold (also used by Sign out). */ + @JvmStatic fun clearCookies(done: (() -> Unit)? = null) { + try { + WebStorage.getInstance().deleteAllData() + CookieManager.getInstance().removeAllCookies { CookieManager.getInstance().flush(); done?.invoke() } + } catch (e: Exception) { done?.invoke() } // no WebView on this device + } + } + + @SuppressLint("SetJavaScriptEnabled") + override fun onCreate(b: Bundle?) { + super.onCreate(b) + open = WeakReference(this) + val d = resources.displayMetrics.density + val root = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL; setBackgroundColor(0xFF2B2D31.toInt()) } + val bar = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL; gravity = Gravity.CENTER_VERTICAL + setPadding((20 * d).toInt(), (10 * d).toInt(), (12 * d).toInt(), (10 * d).toInt()) + } + bar.addView(TextView(this).apply { + text = "Sign in to Xbox"; setTextColor(0xFFFFFFFF.toInt()); setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f) + typeface = android.graphics.Typeface.DEFAULT_BOLD + }, LinearLayout.LayoutParams(0, -2, 1f)) + bar.addView(TextView(this).apply { + text = "Close"; setTextColor(0xFF2FD3E6.toInt()); setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f) + setPadding((16 * d).toInt(), (10 * d).toInt(), (16 * d).toInt(), (10 * d).toInt()) + isFocusable = true; setOnClickListener { finish() } + }) + root.addView(bar, LinearLayout.LayoutParams(-1, -2)) + val web = WebView(this).apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + setBackgroundColor(0xFFFFFFFF.toInt()) + webViewClient = WebViewClient() // every page stays in here, never in the browser + } + root.addView(web, LinearLayout.LayoutParams(-1, 0, 1f)) + setContentView(root) + val url = intent.getStringExtra(EXTRA_URL) ?: "https://www.microsoft.com/link" + // a clean start: no Microsoft account remembered from before, then the page + CookieManager.getInstance().setAcceptCookie(true) + clearCookies { runOnUiThread { if (!isFinishing) web.loadUrl(url) } } + } + + override fun onDestroy() { + if (open?.get() === this) open = null + super.onDestroy() + } +} diff --git a/app/src/tv/java/com/limelight/home/HomeActivity.kt b/app/src/tv/java/com/limelight/home/HomeActivity.kt index 806423a863..465e52e84d 100644 --- a/app/src/tv/java/com/limelight/home/HomeActivity.kt +++ b/app/src/tv/java/com/limelight/home/HomeActivity.kt @@ -911,16 +911,32 @@ class HomeActivity : Activity(), PhoneLink.Listener { val sub = tv.text("Getting a code…", 24, tv.p.dust, tv.ui(500), 3) card.addView(sub, tv.lp(-1, -2).apply { topMargin = tv.px(12) }) val code = tv.text("", 88, tv.p.moon, tv.display(700)).apply { letterSpacing = 0.12f } - card.addView(code, tv.lp(-2, -2).apply { topMargin = tv.px(28); bottomMargin = tv.px(28) }) + // the code, and a QR code a phone camera opens with the code already filled in (made here, no QR service) + val qr = android.widget.ImageView(this).apply { visibility = View.GONE; scaleType = android.widget.ImageView.ScaleType.FIT_CENTER } + val qrNote = tv.text("Or scan with your phone's camera", 20, tv.p.dust, tv.ui(600)).apply { visibility = View.GONE } + card.addView(tv.row().apply { + gravity = Gravity.CENTER_VERTICAL + addView(code, LinearLayout.LayoutParams(0, -2, 1f)) + addView(tv.col().apply { + gravity = Gravity.CENTER_HORIZONTAL + addView(qr, tv.lp(300, 300)) + addView(qrNote, tv.lp(-2, -2).apply { topMargin = tv.px(8) }) + }) + }, tv.lp(-1, -2).apply { topMargin = tv.px(24); bottomMargin = tv.px(20) }) + val privateNote = tv.text("To use a different Microsoft account, open the page in a private browser window.", 22, tv.p.dust, tv.ui(500), 2) + .apply { visibility = View.GONE } + card.addView(privateNote, tv.lp(-1, -2).apply { bottomMargin = tv.px(24) }) card.addView(tv.row().apply { addView(tv.pill("Cancel", "B", false) { hideOverlay() }.apply { tag = "primary" }) }) - showOverlay("xbox", FrameLayout(this).apply { addView(card, tv.flp(1000, -2, Gravity.CENTER)) }) + showOverlay("xbox", FrameLayout(this).apply { addView(card, tv.flp(1200, -2, Gravity.CENTER)) }) Thread { try { val dc = Xbox.startSignIn() runOnUiThread { sub.text = "On your phone, open ${dc.url.removePrefix("https://").removePrefix("www.")} and enter this code. " + - "Then sign in with your Xbox account." + "Then sign in with the account you use on your Xbox." code.text = dc.userCode + Qr.encode(Xbox.linkUrl(dc))?.let { m -> qr.setImageBitmap(Qr.bitmap(m, 8)); qr.visibility = View.VISIBLE; qrNote.visibility = View.VISIBLE } + privateNote.visibility = View.VISIBLE } val end = System.currentTimeMillis() + dc.expiresIn * 1000L while (System.currentTimeMillis() < end && overlayKind == "xbox") { @@ -938,6 +954,11 @@ class HomeActivity : Activity(), PhoneLink.Listener { } } runOnUiThread { if (overlayKind == "xbox") { sub.text = "The code expired. Press B and try again."; code.text = "" } } + } catch (e: Xbox.ProfileError) { + runOnUiThread { + if (overlayKind == "xbox") { sub.text = e.message; code.text = ""; qr.visibility = View.GONE; qrNote.visibility = View.GONE } + render(force = true) + } } catch (e: Exception) { runOnUiThread { if (overlayKind == "xbox") sub.text = "Sign-in didn't work: ${e.message?.take(120) ?: e.javaClass.simpleName}" } } @@ -1103,8 +1124,11 @@ class HomeActivity : Activity(), PhoneLink.Listener { } "xbox" -> { if (Xbox.signedIn(this)) action(list, "set:xbox", "Xbox network", - "Signed in" + (Xbox.gamertag(this)?.let { " as $it" } ?: "") + ". Your consoles show under Consoles", "Sign out") { - confirm("Sign out of Xbox?", "Your Xbox consoles leave this TV until you sign in again.", "Sign out") { + if (Xbox.noProfile(this)) "Signed in, no Xbox profile. Sign out, then sign in with the account you use on your Xbox" + else "Signed in" + (Xbox.gamertag(this)?.let { " as $it" } ?: "") + ". Your consoles show under Consoles", "Sign out") { + if (Xbox.noProfile(this)) confirm("Sign out of Xbox?", "This Microsoft account has no Xbox profile. Sign out to start fresh with another account.", + "Sign out", noPicked = false) { Xbox.signOut(this); xboxes = emptyList(); render(force = true) } + else confirm("Sign out of Xbox?", "Your Xbox consoles leave this TV until you sign in again.", "Sign out") { Xbox.signOut(this); xboxes = emptyList(); render(force = true) } } else action(list, "set:xbox", "Xbox network", "Sign in once to play your Xbox on this TV, at home or away", "Sign in") { xboxSignIn() } if (Xbox.signedIn(this)) {