Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions android/samples/mobile-2/.gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# The repository root sets `* text eol=lf`, which rewrites the CRLF bytes inside
# a jar's compressed entries and shifts its central directory, leaving an
# archive the JVM rejects with "invalid END header". That is how the committed
# gradle-wrapper.jar was corrupted, which made `./gradlew` fail with "Invalid or
# corrupt jarfile" before the wrapper could download anything.
*.jar -text
9 changes: 8 additions & 1 deletion android/samples/mobile-2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,16 @@ interaction traffic.

| Schema action | Android intent | Notes |
|---|---|---|
| `setAlarm` | `AlarmClock.ACTION_SET_ALARM` | Opens the clock app so the user can confirm the alarm. |
| `setAlarm` | `AlarmClock.ACTION_SET_ALARM` | Scheduled in the background (`EXTRA_SKIP_UI = true`) and confirmed with a toast. The optional `days` parameter takes lowercase weekday names and becomes a repeating alarm via `EXTRA_DAYS`; an unrecognised name fails the whole action rather than setting the alarm on a subset of the days asked for. |
| `setTimer` | `AlarmClock.ACTION_SET_TIMER` | Starts the countdown in the background (`EXTRA_SKIP_UI = true`) and confirms with a toast, so a chat request never yanks the user out of the conversation. Durations outside the documented 1..86400 second range are rejected rather than clamped. |
| `showAlarms` | `AlarmClock.ACTION_SHOW_ALARMS` | Opens the clock app's alarm list. Takes no parameters, so the dispatcher sends no `parameters` object and the parser must not require one. |
| `showTimers` | `AlarmClock.ACTION_SHOW_TIMERS` | Opens the clock app's timer list. Added in API 26; on API 24–25 the action reports that the device does not support it instead of throwing. |
| `searchNearby` | `Intent.ACTION_VIEW` with a `geo:0,0?q=` URI | Opens the device's maps app on a local search. The intent is implicit rather than pinned to Google Maps, so it resolves on any device with a maps app. |
| `showLocation` | `Intent.ACTION_VIEW` with a `geo:0,0?q=` URI | Shows one named place. `0,0` means "wherever the query resolves to", so no location permission is involved and no device coordinates are read. |
| `dialPhoneNumber` | `Intent.ACTION_DIAL` with a `tel:` URI | Only pre-fills the dialer — the user still presses call, so no `CALL_PHONE` permission is needed and a hallucinated number cannot dial itself. Numbers are held to a dialable charset and rejected, never rewritten. |
| `composeSms` | `Intent.ACTION_SENDTO` with an `smsto:` URI and `sms_body` | Opens a pre-filled draft — the user still presses send, so no `SEND_SMS` permission is needed. With no recipient the draft opens with an empty To field; an *unusable* recipient is rejected rather than silently dropped. |
| `webSearch` | `Intent.ACTION_WEB_SEARCH` with `SearchManager.QUERY` | The query travels as an extra rather than being spliced into a URL, so it needs no encoding. |
| `openWebPage` | `Intent.ACTION_VIEW` with an `http`/`https` URI | The scheme allowlist is the load-bearing check: `ACTION_VIEW` would otherwise follow `market:`, `file:` or any app's own deep-link scheme, turning "open this page" into an arbitrary-app launcher driven by text the model read. URLs containing whitespace are refused rather than repaired into a different host. |

All actions require the app to be in the foreground: Android 10+ silently refuses
background activity starts (no exception is thrown), so the app checks its own
Expand Down
31 changes: 31 additions & 0 deletions android/samples/mobile-2/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<!--
Since Android 11, resolveActivity returns null for any intent not declared
here, so MainActivity.launchExternalIntent would report "no app available"
on a device where one clearly exists. Every action the androidDevice agent
can launch needs a matching entry.
-->
<queries>
<intent>
<action android:name="android.speech.RecognitionService" />
Expand All @@ -12,10 +18,35 @@
<intent>
<action android:name="android.intent.action.SET_TIMER" />
</intent>
<intent>
<action android:name="android.intent.action.SHOW_ALARMS" />
</intent>
<intent>
<action android:name="android.intent.action.SHOW_TIMERS" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="geo" />
</intent>
<intent>
<action android:name="android.intent.action.DIAL" />
<data android:scheme="tel" />
</intent>
<intent>
<action android:name="android.intent.action.SENDTO" />
<data android:scheme="smsto" />
</intent>
<intent>
<action android:name="android.intent.action.WEB_SEARCH" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="http" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="https" />
</intent>
</queries>

<uses-permission android:name="android.permission.INTERNET" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@
export type AndroidDeviceAction =
| SetAlarmAction
| SetTimerAction
| SearchNearbyAction;
| SearchNearbyAction
| ShowAlarmsAction
| ShowTimersAction
| ShowLocationAction
| DialPhoneNumberAction
| ComposeSmsAction
| WebSearchAction
| OpenWebPageAction;

// Sets an alarm on the Android device.
// Use when the user asks to create or schedule an alarm for a time of day.
Expand All @@ -15,6 +22,17 @@ export type SetAlarmAction = {
originalRequest: string;
// Local time of day in HH:mm format. The device schedules the next occurrence.
time: string;
// Days the alarm repeats on. Omit for a one-off alarm that rings at the
// next occurrence of the given time.
days?: (
| "monday"
| "tuesday"
| "wednesday"
| "thursday"
| "friday"
| "saturday"
| "sunday"
)[];
};
};

Expand All @@ -41,3 +59,82 @@ export type SearchNearbyAction = {
searchTerm: string;
};
};

// Opens the clock app on its list of alarms.
// Use when the user asks to see, check or review their alarms.
export type ShowAlarmsAction = {
actionName: "showAlarms";
};

// Opens the clock app on its list of countdown timers.
// Use when the user asks to see, check or review their timers.
export type ShowTimersAction = {
actionName: "showTimers";
};

// Opens the Android device's maps app centred on one specific place.
// Use when the user names a particular address or landmark to show or get
// directions to, rather than asking to search for a category of place nearby.
export type ShowLocationAction = {
actionName: "showLocation";
parameters: {
// The original user request.
originalRequest: string;
// A postal address or place name, for example "1 Microsoft Way, Redmond WA".
location: string;
};
};

// Opens the Android device's phone dialer pre-filled with a number.
// The user still has to press the call button, so this never places a call by
// itself. Use when the user asks to call or phone someone.
export type DialPhoneNumberAction = {
actionName: "dialPhoneNumber";
parameters: {
// The original user request.
originalRequest: string;
// The number to dial. Digits, spaces and the characters + - ( ) . # * only.
phoneNumber: string;
};
};

// Opens the Android device's messaging app on a pre-filled draft text message.
// The user still has to press send, so this never sends a message by itself.
// Use when the user asks to text or message someone.
export type ComposeSmsAction = {
actionName: "composeSms";
parameters: {
// The original user request.
originalRequest: string;
// The message body to pre-fill.
message: string;
// The recipient's number. Omit when the user did not name a recipient;
// the messaging app then opens with an empty recipient field.
phoneNumber?: string;
};
};

// Runs a search in the Android device's own browser or search app.
// Use only when the user explicitly asks to search on their phone or to see
// results in their browser - otherwise answer the question directly instead.
export type WebSearchAction = {
actionName: "webSearch";
parameters: {
// The original user request.
originalRequest: string;
// The search query.
query: string;
};
};

// Opens a web page in the Android device's browser.
// Use when the user asks to open or visit a specific web address.
export type OpenWebPageAction = {
actionName: "openWebPage";
parameters: {
// The original user request.
originalRequest: string;
// An absolute http:// or https:// URL. Other schemes are rejected.
url: string;
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package com.example.typeagentchat

import org.json.JSONObject

/**
* Shared validation helpers for the `androidDevice` action parsers.
*
* Every value that reaches these helpers was shaped by an LLM on the server and
* ends up inside an `Intent` that this app hands to another app, so the rules
* here are the client-side half of the trust boundary. The server-side
* dispatcher validates against `androidDeviceSchema.ts`; this re-validates
* because a schema says what the model *should* emit, not what actually arrives.
*/

/**
* Intent extras and data travel through a binder transaction with a ~1 MB
* budget, and an oversized value makes `startActivity` throw
* `TransactionTooLargeException` - a `RuntimeException` no caller expects. Cap
* every free-text field so a hostile or buggy server cannot crash the app from
* the network.
*/
internal const val MAX_ACTION_TEXT_CHARS = 256

private val controlCharRegex = Regex("""\p{Cntrl}""")
private val whitespaceRunRegex = Regex("""\s+""")

/**
* Reads a string field, rejecting the JSON-null trap.
*
* Not `optString`: Android's `org.json` renders a JSON null as the literal
* string `"null"`, which would otherwise be searched for, dialled or sent
* verbatim.
*/
internal fun JSONObject.optActionString(name: String): String =
(opt(name) as? String).orEmpty()

/**
* Folds control characters (newlines and tabs included) into spaces, collapses
* whitespace runs and caps the length.
*
* Control characters have no meaning in a search query, address or message and
* would only survive as percent escapes, so removing them keeps both the URI
* and the confirmation toast readable when the model emits multi-line text.
*/
internal fun sanitizeActionText(raw: String, maxChars: Int = MAX_ACTION_TEXT_CHARS): String =
raw.replace(controlCharRegex, " ")
.replace(whitespaceRunRegex, " ")
.trim()
.take(maxChars)
.trim()

/** Convenience for the common "read, sanitize, cap" sequence. */
internal fun JSONObject.sanitizedActionText(
name: String,
maxChars: Int = MAX_ACTION_TEXT_CHARS
): String = sanitizeActionText(optActionString(name), maxChars)

/**
* Percent-encodes a value for use inside a URI.
*
* Done by hand rather than with `Uri.encode` so the parsers stay unit-testable
* on the JVM, and with `URLEncoder` out because it emits `+` for spaces, which
* is only correct for form bodies.
*/
internal fun percentEncode(value: String): String {
val builder = StringBuilder()
for (byte in value.toByteArray(Charsets.UTF_8)) {
val char = (byte.toInt() and 0xFF).toChar()
val unreserved = char in 'A'..'Z' || char in 'a'..'z' || char in '0'..'9' ||
char == '-' || char == '_' || char == '.' || char == '~'
if (unreserved) {
builder.append(char)
} else {
builder.append('%').append("%02X".format(byte))
}
}
return builder.toString()
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,41 @@
package com.example.typeagentchat

import org.json.JSONArray
import org.json.JSONObject
import java.util.Calendar

internal data class SetAlarmAction(
val originalRequest: String,
val hour: Int,
val minute: Int
val minute: Int,
/**
* Days the alarm repeats on, as `java.util.Calendar` day-of-week constants
* ready for `AlarmClock.EXTRA_DAYS`. Empty means a one-off alarm.
*/
val days: List<Int> = emptyList()
)

private val alarmTimeRegex =
Regex("""^(?:\d{4}-\d{2}-\d{2}T)?(\d{2}):(\d{2})(?::\d{2})?$""")

/**
* The closed set of day names the schema offers, mapped to the `Calendar`
* constants `AlarmClock.EXTRA_DAYS` is documented to take.
*
* A map rather than a parse of whatever the model emits: an unrecognised day
* name means the alarm would repeat on the wrong days or on none, and silently
* guessing is worse than refusing.
*/
private val dayNamesToCalendarDays = mapOf(
"monday" to Calendar.MONDAY,
"tuesday" to Calendar.TUESDAY,
"wednesday" to Calendar.WEDNESDAY,
"thursday" to Calendar.THURSDAY,
"friday" to Calendar.FRIDAY,
"saturday" to Calendar.SATURDAY,
"sunday" to Calendar.SUNDAY
)

internal fun parseSetAlarmActionPayload(data: Any?): SetAlarmAction? {
val payload = data as? JSONObject ?: return null
val originalRequest = payload.optString("originalRequest").trim()
Expand All @@ -26,9 +51,55 @@ internal fun parseSetAlarmActionPayload(data: Any?): SetAlarmAction? {
return null
}

val days = parseAlarmDays(payload.opt("days")) ?: return null

return SetAlarmAction(
originalRequest = originalRequest,
hour = hour,
minute = minute
minute = minute,
days = days
)
}

/**
* Reads the optional `days` array.
*
* @return the `Calendar` constants in schema order with duplicates removed, an
* empty list when the field is absent, or null when any entry is not a
* recognised day name - which fails the whole action rather than quietly
* setting an alarm for a subset of the days the user asked for.
*/
private fun parseAlarmDays(raw: Any?): List<Int>? {
if (raw == null || raw == JSONObject.NULL) {
return emptyList()
}
val array = raw as? JSONArray ?: return null
val days = LinkedHashSet<Int>()
for (index in 0 until array.length()) {
val name = (array.opt(index) as? String)?.trim()?.lowercase() ?: return null
days.add(dayNamesToCalendarDays[name] ?: return null)
}
return days.toList()
}

/**
* Human-readable repeat days for the confirmation toast, e.g. "Mon, Wed, Fri".
*
* Ordered Monday-first regardless of the order the model listed them, so the
* toast reads the way a week does rather than echoing the request's phrasing.
*/
internal fun formatAlarmDays(days: List<Int>): String {
val labels = mapOf(
Calendar.MONDAY to "Mon",
Calendar.TUESDAY to "Tue",
Calendar.WEDNESDAY to "Wed",
Calendar.THURSDAY to "Thu",
Calendar.FRIDAY to "Fri",
Calendar.SATURDAY to "Sat",
Calendar.SUNDAY to "Sun"
)
return labels.keys
.filter { it in days }
.joinToString(", ") { labels.getValue(it) }
}

Loading
Loading