Add Google Ecosystem Sync sample app#2
Conversation
- GoogleSyncEntry.kt: record audio → Whisper transcription → LLM extraction → sync to Google Drive/Calendar/Tasks - README.md with setup instructions and architecture overview - Pre-built example.apk for quick testing Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a new google_sync/ sample app demonstrating an end-to-end “conversation → transcription → extraction → Google sync” workflow within the existing single-file-entry sample-app structure of this repository.
Changes:
- Added a new
google_syncsample with a Kotlin entry implementing audio recording, Whisper transcription, LLM JSON extraction, and syncing to Google Drive/Calendar/Tasks. - Added
google_sync/README.mdwith setup and architecture notes, and updated the rootREADME.mdto link/run the sample. - Included a pre-built
google_sync/example.apk.
Reviewed changes
Copilot reviewed 3 out of 4 changed files in this pull request and generated 9 comments.
| File | Description |
|---|---|
| README.md | Adds a new top-level section describing the google_sync sample and how to run it. |
| google_sync/README.md | Documents setup requirements, settings, and the pipeline architecture. |
| google_sync/GoogleSyncEntry.kt | Implements the sample app’s pipeline and Google API calls. |
| google_sync/example.apk | Bundled pre-built APK for the sample. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| val chunks = withTimeoutOrNull(durationMs) { | ||
| session.audio.toList() | ||
| } ?: emptyList() | ||
| session.stop() |
There was a problem hiding this comment.
Audio recording will almost always produce an empty chunks list here: session.audio.toList() won’t complete until you stop the session, so the withTimeoutOrNull will time out and return null, causing you to drop all partially collected audio. This makes pcmBytes empty and the command will typically report “No speech detected.” Collect into a buffer while the timeout runs (or cancel collection after delay(durationMs)), then stop the session and transcribe the buffered bytes.
| val resp = client.post("https://www.googleapis.com/calendar/v3/calendars/primary/events") { | ||
| header("Authorization", "Bearer $token") | ||
| contentType(ContentType.Application.Json) | ||
| setBody(json) | ||
| } | ||
| if (resp.status.value in 200..299) created++ | ||
| } | ||
| return created |
There was a problem hiding this comment.
Non-2xx responses from the Calendar API are silently ignored (you only increment created on success). This prevents extractAndSync from surfacing errors and makes failures look like “0 events created.” Check resp.status and throw (or return an error) with a truncated response body similar to the Drive sync path.
| contentType(ContentType.Application.Json) | ||
| setBody(json) | ||
| } | ||
| if (resp.status.value in 200..299) created++ |
There was a problem hiding this comment.
Non-2xx responses from the Tasks API are silently ignored (you only increment created on success). This prevents extractAndSync from surfacing errors and makes failures look like “0 tasks created.” Check resp.status and throw (or return an error) with a truncated response body similar to the Drive sync path.
| if (resp.status.value in 200..299) created++ | |
| if (resp.status.value !in 200..299) { | |
| val body = try { | |
| resp.bodyAsText() | |
| } catch (t: Throwable) { | |
| "" | |
| } | |
| val truncatedBody = | |
| if (body.length > 1000) body.substring(0, 1000) + "...(truncated)" else body | |
| throw RuntimeException( | |
| "Google Tasks API error ${resp.status.value}: $truncatedBody" | |
| ) | |
| } | |
| created++ |
| private fun withTimezone(dt: String): String { | ||
| if (dt.contains('+') || dt.contains('Z') || dt.matches(Regex(".*-\\d{2}:\\d{2}$"))) return dt | ||
| val tz = TimeZone.getDefault() | ||
| val off = tz.rawOffset | ||
| val h = Math.abs(off) / 3600000 | ||
| val m = (Math.abs(off) % 3600000) / 60000 | ||
| val sign = if (off >= 0) "+" else "-" | ||
| return "$dt$sign${"%02d".format(h)}:${"%02d".format(m)}" | ||
| } |
There was a problem hiding this comment.
withTimezone uses TimeZone.rawOffset, which ignores daylight saving time and can shift event times by an hour during DST. Use an offset computed for the relevant instant (e.g., tz.getOffset(...)) or format the datetime with java.time (ZonedDateTime/OffsetDateTime) in the user’s local zone.
| ctx.log("No speech detected.") | ||
| return ctx.client.display("No speech detected.", DisplayOptions()) | ||
| } | ||
| ctx.log("Transcript: $transcript") |
There was a problem hiding this comment.
This logs the full transcript to app logs, which may include sensitive/PII content and can be accessible via device logs. Consider removing this log line or logging only a short redacted/truncated preview behind a debug flag.
| ctx.log("Transcript: $transcript") | |
| ctx.log("Transcript captured (length=${transcript.length} chars)") |
| tasks.add(TaskItem( | ||
| title = o.getString("title"), | ||
| notes = o.optString("notes", ""), | ||
| dueDate = o.optString("due_date", null).takeIf { !it.isNullOrBlank() && it != "null" }, | ||
| )) | ||
| } |
There was a problem hiding this comment.
optString("due_date", null) can return null when the key is missing; calling .takeIf { ... } on that result can throw at runtime. Use a non-null default (e.g. empty string) and then map blank/"null" to null, or check o.isNull("due_date") before reading.
| description = o.optString("description", ""), | ||
| startDateTime = o.getString("start"), | ||
| endDateTime = o.getString("end"), | ||
| location = o.optString("location", null).takeIf { !it.isNullOrBlank() && it != "null" }, |
There was a problem hiding this comment.
optString("location", null) can return null when the key is missing; calling .takeIf { ... } on that result can throw at runtime. Use a non-null default (e.g. empty string) and then map blank/"null" to null, or check o.isNull("location") before reading.
| location = o.optString("location", null).takeIf { !it.isNullOrBlank() && it != "null" }, | |
| location = o.optString("location", "").let { if (it.isBlank() || it == "null") null else it }, |
| | Setting | Description | | ||
| |---------|-------------| | ||
| | API Base URL | OpenAI-compatible endpoint (default: `https://api.openai.com/v1/`) | | ||
| | API Key | Your API key | | ||
| | Model | LLM model for extraction (default: `gpt-4o-mini`) | | ||
| | Google OAuth2 Access Token | Bearer token with Drive, Calendar, Tasks scopes | | ||
| | Listen Duration | How long to record audio in seconds (default: 30) | | ||
|
|
There was a problem hiding this comment.
The settings table uses || at the start of each row, which breaks standard Markdown table rendering. Use a single leading | per row (e.g. | Setting | Description |).
| companion object { | ||
| private const val TAG = "GoogleSync" | ||
| const val KEY_GOOGLE_TOKEN = "google_oauth_token" | ||
| const val KEY_LISTEN_DURATION = "listen_duration_secs" | ||
| const val KEY_MANUAL_TEXT = "manual_text_input" | ||
| } |
There was a problem hiding this comment.
TAG is declared but not used, and the log calls use a hard-coded string instead. Either use TAG consistently (e.g., Log.e(TAG, ...)) or remove the constant to avoid unused code.
- IndoorNavEntry.kt: AR glasses indoor navigation for hospitals/malls with 4 commands: Where Am I, Navigate To, Find Nearest, Read Signs - README.md with setup instructions and architecture overview - Pre-built example.apk for quick testing Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Each sample has its own README.md inside its folder. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Summary
google_sync/sample: record audio → Whisper transcription → LLM extraction → sync to Google Drive / Calendar / TasksGoogleSyncEntry.kt(single-file entry),README.md(setup & architecture), and pre-builtexample.apkREADME.mdwith google_sync sectionTest plan
xg-glass run GoogleSyncEntry.ktbuilds and runs successfully🤖 Generated with Claude Code