跳到正文 Lab Week 8 Summary · Jimmy Huang's Blog
← Week 8 Network Connection in Android and Retrofit 学习 › C6001 › 2026 S1 › FIT5046 Mobile and distributed computing systems › Week 8 Network Connection in Android and Retrofit Lab Week 8 Summary Lab Week 8 Summary 本页目录· 73 条 Lab后总结 › Overview 本周 Lab 的主题是 Retrofit and Google Search 。
这次实验不是继续做本地数据库,而是让 Android App 通过网络访问一个公开 API。主线可以理解为:
先在 Google Cloud / Programmable Search Engine 里申请两个关键值: API key
Search engine ID,也叫 cx
在 Android 项目中加入 Retrofit、Gson 和 Internet 权限。
用 Kotlin data class 映射 Google Custom Search API 返回的 JSON。
用 Retrofit Interface 定义 HTTP GET 请求。
用 Repository 和 ViewModel 把网络请求结果交给 Compose UI。
最后在 App 里输入关键词,显示 Google 搜索结果中的第一条 snippet。
这周真正要理解的是: Android App 不是直接“魔法式”拿到网页数据,而是通过 API URL + query parameters 发出 HTTP 请求,然后把返回的 JSON 转成 Kotlin object,再显示到 UI 上。
注意:本次上传的 SubjectItems.zip 是 Room CRUD 示例代码,它和本周 Retrofit Lab 不是同一个项目。它可以帮助你理解 Repository / ViewModel 分层思想,但不要把 SubjectDAO、 SubjectDatabase、 SubjectEntity 这些 Room 文件复制进本次 GoogleSearchLab 项目。 Goal 本页目录 如何申请 Google Custom Search API 的 API key
如何创建 Programmable Search Engine 并获得 cx
如何给 Android 项目添加 Retrofit / Gson 依赖
为什么 App 访问网络必须声明 INTERNET permission
如何用 Retrofit 的 @GET 和 @Query 发送 GET 请求
如何用 data class 映射 JSON response
如何通过 Repository + ViewModel + Compose UI 显示网络请求结果
Task 0: 准备工作与安全提醒 这个 Task 先把实验开始前必须准备好的东西讲清楚,避免后面做到一半卡住。
需要一个 personal Gmail account
不要使用 Monash email account
需要 Android Studio 和一个可以运行 Compose App 的 Emulator / 真机
需要联网
需要保存好 API key 和 Search engine ID (cx)
不要把真实 API key 上传到公开 GitHub,也不要在截图里完整暴露
Step 0.1:确认你使用的是个人 Gmail 当前登录的是个人 Gmail
不是 Monash 邮箱
如果浏览器里已经登录 Monash 邮箱,建议: 退出 Monash 账号;或
直接使用另一个浏览器;或
使用无痕窗口重新登录个人 Gmail
Google Cloud Console 和 Programmable Search Engine 后面都会绑定到这个 Google account。Lab 明确要求不要使用 Monash email,所以这里先避免账号选错。
你已经准备好一个可用的个人 Gmail,并且后续申请的 key 都会归在这个账号下面。
Step 0.2:准备一个安全记录 key 的地方 后面写 ItemsRepository.kt 时需要把这两个值放进去。如果你不先保存,后面容易找不到或复制错。
这个文件只放在本机,不要上传到 GitHub,不要交作业时附上真实 key,不要把完整 key 截图放进文档。
Task 1: 获取 Google Custom Search API Key 这个 Task 要做的是在 Google Cloud Console 里创建项目、启用 Custom Search API,然后生成一个 API key。
这个 key 后面会作为请求参数之一传给 Google Custom Search API。
Step 1.1:进入 Google Cloud Console 项目页面 如果你看到的是 Google Cloud Console 的项目选择 / 管理页面,说明进入正确。
Google API key 必须属于某个 Google Cloud Project,所以第一步是先创建或选择一个 project。
Step 1.2:创建一个新的 Google Cloud Project Project 可以理解成 Google Cloud 里的一组资源容器。你后面启用的 API、创建的 credentials 都会归在这个 project 下面。
项目创建完成后,你应该可以在项目列表中看到这个 project,并且页面顶部当前项目切换到你刚创建的项目。
截图占位:这里放 Google Cloud Project 创建完成后的截图,注意不要泄露敏感信息
Step 1.3:进入 Enabled APIs & services 在 Google Cloud Console 左上角点击菜单图标,然后找到:
这里是管理当前 project 已启用 API 的地方。因为我们要调用 Google Custom Search API,所以必须先启用它。
Step 1.4:打开 API Library Lab 文档里是在 Others 分类下面找到 Google Custom Search,但 Google 页面可能会更新,所以你也可以直接用搜索框搜索。
Google Cloud 默认不会自动给项目开放所有 API。你必须明确告诉 Google:这个 project 要使用 Custom Search API。
Step 1.5:启用 Custom Search API 进入 Custom Search API 页面后,点击:
启用成功后,你会进入该 API 的管理页面,或者可以在 Enabled APIs & services 中看到 Custom Search API。
Step 1.6:创建 API key 系统会弹出一个窗口,显示你新创建的 API key。
API key 是你的 App 调用 Google API 时的身份凭证。没有 key,请求通常会被 Google 拒绝。
Step 1.7:如果忘记复制 API key,如何找回 Lab 里建议保持默认设置,不需要额外修改 key restrictions。
Task 2: 获取 Search Engine ID,也就是 cx 这个 Task 要做的是创建一个 Programmable Search Engine,并拿到它的 Search engine ID。
Step 2.1:进入 Programmable Search Engine API key 只是允许你调用 Google API,而 cx 决定你调用的是哪一个自定义搜索引擎。
Step 2.2:创建第一个 Search Engine 如果页面要求填写 site,可以先填一个和搜索主题相关的网站,或者按页面提示选择 Search the entire web。
Programmable Search Engine 本质上是在 Google 搜索之上创建一个可被 API 调用的搜索配置。
Step 2.3:进入 Customise / Control Panel 或者进入该 search engine 的 Control Panel。
cx 会告诉 Google Custom Search API:这次搜索使用哪个 Programmable Search Engine 的配置。
Step 2.4:可选:添加 Sites to Search 如果你添加了特定网站,搜索结果会更偏向这些网站。Lab 文档提到,即使不添加网站,搜索也可以工作;添加网站只是为了提高某些场景下的搜索准确度。
为了 Lab 演示简单,先保持 Search the entire web,等 App 能正常跑起来后再尝试添加网站。
Task 3: 创建 Android 项目并添加 Retrofit 依赖和 Internet 权限 这个 Task 开始正式进入 Android Studio。
Step 3.1:创建新项目 GoogleSearchLab 如果 Android Studio 自动生成 package name,例如:
后面 Kotlin 文件的第一行 package 要和你的实际 package 保持一致。
本周 Lab 是一个独立 Retrofit 网络请求练习,所以建议新建干净项目,不要和 Room 项目混在一起。
新项目可以正常打开,并且默认 MainActivity.kt 能运行。
Step 3.2:在 module-level Gradle 添加依赖 打开 module-level Gradle 文件,通常是:
注意不是 project-level 的 build.gradle.kts。
在 dependencies { ... } 里面加入:
Step 3.3:处理 Android Studio 的黄色提示 Lab 文档要求:如果这些 dependency 出现黄色下划线,可以点击前面的黄色灯泡,然后选择类似:
Android Studio 可能会把依赖移动到:
然后你的 app/build.gradle.kts 里可能变成类似:
你的 alias 名称可能不完全一样,例如 libs.converter.gson、 libs.retrofit2.converter.gson 都有可能。只要 Gradle Sync 成功即可。
Step 3.4:Sync Gradle 点击 Android Studio 顶部提示中的:
Gradle Sync 成功,没有 dependency unresolved 的报错。
如果 retrofit2 import 不出来:先确认 Gradle Sync 是否成功。
如果 dependency 写在 project-level Gradle:移到 app/build.gradle.kts。
如果 version catalog alias 报错:检查 libs.versions.toml 里的 alias 名称和 build.gradle.kts 中是否一致。
Step 3.5:添加 Internet permission 在 <manifest> 里面、 <application> 前面添加:
Android App 访问网络属于敏感能力,需要在 Manifest 中声明。没有这个 permission,App 的 HTTP 请求可能无法正常访问外部网络。
不要直接照抄上面整个 Manifest 覆盖你的文件,因为你的 theme 名、application 配置可能不同。关键是添加这一行:
Task 4: 创建 Retrofit Model Classes 这个 Task 要创建两个 Kotlin data class,用来映射 Google Custom Search API 返回的 JSON。
Step 4.1:理解 JSON 到 Kotlin object 的对应关系 Google Custom Search API 返回的大概结构可以理解为:
我们不需要把整个 JSON 的所有字段都写出来。Gson 可以只映射我们关心的字段。
Model class 的作用是告诉 Gson:JSON 中的哪些字段要变成 Kotlin 对象中的哪些属性。
Step 4.2:创建 SearchResponse.kt如果你的 package name 不是 com.example.googlesearchlab,请把第一行替换成你自己的 package。
SearchResponse 对应整个 JSON response。因为 Google 返回的搜索结果列表字段叫 items,所以这里也写成 items。
Step 4.3:创建 Items.ktItems 对应 items 数组里的单个搜索结果。Lab 只要求显示 snippet,所以这里先只写 snippet。
如果某些结果没有 snippet 字段,默认值可以减少空值或解析问题,让 App 更稳定。
Task 5: 创建 Retrofit Interface 和 Retrofit Object
Step 5.1:创建 RetrofitInterface.kt@GET("customsearch/v1"):表示请求 endpoint 是 customsearch/v1
@Query("key"):把 apiKey 放到 URL query parameter key 里
@Query("cx"):把 Search Engine ID 放到 cx 里
@Query("q"):把用户输入的搜索关键词放到 q 里
suspend:表示这个函数要在 coroutine 里调用,因为网络请求不能阻塞主线程
SearchResponse:表示返回 JSON 会被 Gson 转成 SearchResponse object
因为 key、 cx、 q 是 URL 问号后面的参数,不是路径的一部分。
Step 5.2:创建 RetrofitObject.kt它负责创建 Retrofit 实例,并把 RetrofitInterface 变成可以真正发请求的 service。
object RetrofitObject:Kotlin singleton,整个 App 只创建一个 RetrofitObject
BASE_URL:Google API 的基础地址
.baseUrl(BASE_URL):告诉 Retrofit 请求从哪里开始
.addConverterFactory(GsonConverterFactory.create()):告诉 Retrofit 用 Gson 解析 JSON
.build():创建 Retrofit 实例
.create(RetrofitInterface::class.java):根据 interface 生成真实 API service
by lazy:第一次使用时才创建,避免 App 启动时做不必要初始化
Retrofit 的 baseUrl 必须以 / 结尾,所以这里写:
Task 6: 创建 Repository 和 ViewModel
Step 6.1:创建 ItemsRepository.kt写入下面代码,并把两个 placeholder 换成你自己的值:
Repository 是 ViewModel 和 Retrofit 之间的中间层。它把原本需要三个参数的 API 请求:
UI 和 ViewModel 不应该关心 API key 这种细节。Repository 负责组织数据来源和请求细节,让上层代码更干净。
这次 Lab 可以按要求先把 key 写在 Repository 里。但真实项目不要把 API key hardcode 到代码并上传公开仓库。如果你要上传 GitHub,至少要先删除真实 key 或改成 placeholder。
Step 6.2:创建 RetrofitViewModel.ktViewModel 负责处理 UI 触发的搜索动作,并保存搜索结果。
repository:ViewModel 通过它访问网络数据
retrofitResponse:保存 API 返回结果
isLoading:告诉 UI 当前是否正在搜索
errorMessage:告诉 UI 是否有错误信息
viewModelScope.launch { ... }:启动 coroutine 调用 suspend function
try / catch:防止网络错误导致 App 崩溃
为什么 ViewModel 里不用 remember
remember 是 Composable 里的状态保存方式。ViewModel 不是 Composable,所以这里直接用 mutableStateOf。
customSearch() 是 suspend function,必须在 coroutine scope 里调用。 viewModelScope 会跟随 ViewModel 生命周期自动取消任务,比手动创建 coroutine 更安全。
Task 7: 编写 Compose UI 并运行 App
Step 7.1:替换或整理 MainActivity.kt注意:如果你的 package name 或 theme name 不同,需要替换:
MainActivity 负责启动 Compose UI,并创建 RetrofitViewModel。 SearchResults 负责显示输入框、按钮和搜索结果。
private val viewModel: RetrofitViewModel by viewModels():Activity 创建 ViewModel
var keyword by remember { mutableStateOf("") }:保存用户当前输入
val itemsReturned by viewModel.retrofitResponse:读取 ViewModel 中的 API response state
Button(onClick = { viewModel.customSearch(keyword) }):点击按钮后发起搜索
itemsReturned.items[0].snippet:显示第一条搜索结果的 snippet
Step 7.2:检查最终文件结构 如果你看到 SubjectDAO.kt、 SubjectDatabase.kt、 SubjectEntity.kt 出现在这个项目里,说明你可能把 Room 示例代码混进来了。本次 Retrofit Lab 不需要这些文件。
Step 7.3:运行 App 点击 Android Studio 的 Run 按钮,运行到 Emulator 或真机。
具体返回内容不一定和 Lab 截图完全一样,因为 Google 搜索结果会随时间、地区和 Search Engine 设置变化。只要能显示第一条结果的 snippet,就说明核心功能完成。
Step 7.4:用 Logcat 排查失败原因 我们在 ViewModel 的 catch 里写了:
Task 8: 可选验证:直接用浏览器或 curl 测试 API 这个 Task 不是必须,但很适合用来判断问题到底出在 Google key,还是 Android 代码。
Step 8.1:用浏览器测试 API URL 把下面 URL 中的 placeholder 换成你自己的值:
如果浏览器返回一大段 JSON,并且里面有 items,说明 Google API key 和 cx 基本可用。
如果返回 403、API not enabled、key invalid 之类错误,说明问题优先排查 Google Cloud / Programmable Search Engine 设置。
Step 8.2:用 curl 测试 终端输出 JSON,里面包含 items 和 snippet。
截图占位:这里放浏览器或 curl 返回 JSON 的截图,注意遮住完整 key
Summary 这周 Lab 的主线是: 让 Android App 通过 Retrofit 调用 Google Custom Search API,并把 JSON response 显示到 Compose UI 上。
这周最重要的不是记住每一行代码,而是理解几个分层:
Model 负责描述 JSON 数据结构
RetrofitInterface 负责描述 HTTP request
RetrofitObject 负责创建 Retrofit client
Repository 负责封装数据来源和 key
ViewModel 负责调用 suspend function 并保存 UI state
Composable 负责显示输入框、按钮和结果
本周必须理解的知识点
1. API key 和 cx 不是同一个东西 API key 是 Google API 的访问凭证。
cx 是 Programmable Search Engine 的 ID。
一次 Custom Search API 请求需要同时带上它们:
2. Retrofit 的 base URL 和 endpoint 会拼起来 再加上 query parameters 后就是:
3. @Query 对应 URL 问号后面的参数
4. 网络请求必须放在 coroutine 里 Retrofit interface 中的函数是:
这意味着它不能直接在普通函数里调用,必须放进 coroutine,例如:
5. ViewModel 里的 state 变化会触发 Compose 更新 当 retrofitResponse.value 被更新后,Compose 会重新执行相关 UI,页面就能显示新的 snippet。
常见报错与排查
1. Unresolved reference: retrofit2打开 app/build.gradle.kts
确认 dependencies 有 Retrofit / converter-gson / gson
点击 Sync Now
再检查 import 是否恢复
2. Suspend function should be called only from a coroutine原因:你直接调用了 suspend function。
3. App 显示 Search failed API_KEY 是否替换成真实值
SEARCH_ENGINE_ID 是否替换成真实 cx
Google Cloud 是否启用了 Custom Search API
AndroidManifest.xml 是否添加了 INTERNET permission
Emulator / 真机是否联网
Logcat 中具体报错是什么
4. 返回结果为空,页面一直显示 No results yet 先用关键词 Paris 或 Monash University 测试
用浏览器直接打开 API URL 看 JSON 是否有 items
检查 Programmable Search Engine 是否设置为 search entire web
5. baseUrl must end in /原因:Retrofit 要求 base URL 以 / 结尾。
6. by、 getValue、 setValue 报错
关键概念对比
RetrofitInterface vs RetrofitObject RetrofitInterface 描述 API 怎么调用。
RetrofitObject 创建真正的 Retrofit client。
Repository vs ViewModel ViewModel 负责 UI state 和生命周期。
remember vs ViewModel state remember 用在 Composable 内部,适合保存短期 UI 输入,例如:
ViewModel state 适合保存和业务逻辑有关的数据,例如:
Room 示例和 Retrofit 示例的相同点 虽然 SubjectItems.zip 是 Room 示例,不属于本次 Lab 必做内容,但它和 Retrofit Lab 有一个共同思想:都用 Repository / ViewModel 分层。
实验问题回答草稿
Q1: What is the purpose of Retrofit in this lab? Retrofit is used to create the network connection between the Android app and the Google Custom Search API. It allows the app to define an HTTP GET request using annotations such as @GET and @Query, and it converts the JSON response into Kotlin model classes through Gson.
Q2: Why do we need model classes such as SearchResponse and Items? The API returns data in JSON format. Model classes define the structure of the parts of the JSON response that the app wants to use. In this lab, SearchResponse maps the whole response and contains a list of Items, while Items contains the snippet field displayed in the UI.
Q3: Why is customSearch() a suspend function? The search request is a network operation and may take time to complete. Marking it as suspend allows it to run inside a coroutine without blocking the main UI thread. In this lab, the function is called inside viewModelScope.launch.
Q4: Why do we use a Repository? The Repository separates data access logic from the ViewModel and UI. It hides the API key, search engine ID, and Retrofit service call details, so the ViewModel only needs to call customSearch(keyword).
Q5: Why do we add Internet permission to AndroidManifest.xml? Android apps must declare network access permission before they can use the Internet. Since this app calls the Google Custom Search API, it needs android.permission.INTERNET in the manifest file.
老师可能会问什么
1. 这次 App 发出的完整 API 请求长什么样? 其中 key 是 API key, cx 是 search engine ID, q 是用户输入的搜索关键词。
2. @GET("customsearch/v1") 和 BASE_URL 是怎么组合的?
3. 为什么 UI 里不直接调用 Retrofit? 因为这样会让 UI 同时负责显示、网络请求、API key 管理和错误处理,代码会混乱。使用 Repository 和 ViewModel 可以分离职责,让 UI 只关心显示和用户交互。
4. 如果 Google API 返回的 items 是空的会怎样?
5. 为什么要用 try / catch? 网络请求可能因为 key 错误、没有网络、API 没启用、quota 超限等原因失败。 try / catch 可以捕获异常,避免 App 直接崩溃,并且可以通过 Logcat 查看错误原因。
Google Custom Search API Key:
YOUR_API_KEY_HERE
Search Engine ID / cx:
YOUR_SEARCH_ENGINE_ID_HEREhttps://console.developers.google.com/project+ ENABLE APIS AND SERVICESAPI key:
YOUR_API_KEY_HEREAPIs & Services -> Credentials -> API KeysSearch engine ID / cx = 一串搜索引擎 IDkey = API key
cx = Search engine ID
q = 用户输入的关键词https://www.google.com/cse/Create your first search engineSearch engine name: GoogleSearchLab
What to search: Search the entire webSearch Engine ID / cx:
YOUR_SEARCH_ENGINE_ID_HERESearch Features -> Sites to SearchProject name: GoogleSearchLab
Template: Empty Activity / Empty Compose Activity
Language: Kotlin
UI: Jetpack Composecom.example.googlesearchlabReplace with new library catalog declarationgradle/libs.versions.toml方式 A:直接 implementation("group:name:version")
方式 B:使用 libs.xxx version catalogapp/src/main/AndroidManifest.xmlapp/src/main/java/com/example/googlesearchlab/SearchResponse.ktapp/src/main/java/com/example/googlesearchlab/Items.ktapp/src/main/java/com/example/googlesearchlab/RetrofitInterface.ktGET https://www.googleapis.com/customsearch/v1?key=...&cx=...&q=...app/src/main/java/com/example/googlesearchlab/RetrofitObject.kthttps://www.googleapis.com/https://www.googleapis.comCompose UI
↓
RetrofitViewModel
↓
ItemsRepository
↓
RetrofitObject.retrofitService
↓
Google Custom Search APIapp/src/main/java/com/example/googlesearchlab/ItemsRepository.ktapp/src/main/java/com/example/googlesearchlab/RetrofitViewModel.ktapp/src/main/java/com/example/googlesearchlab/MainActivity.ktapp/src/main/java/com/example/googlesearchlab/
├── MainActivity.kt
├── SearchResponse.kt
├── Items.kt
├── RetrofitInterface.kt
├── RetrofitObject.kt
├── ItemsRepository.kt
└── RetrofitViewModel.ktResults: Paris is the capital and most populous city of France...Search failed. Check API key, cx, Internet permission, or Logcat.https://www.googleapis.com/customsearch/v1?key=YOUR_API_KEY_HERE&cx=YOUR_SEARCH_ENGINE_ID_HERE&q=Paris用户输入 keyword
↓
Compose Button onClick
↓
RetrofitViewModel.customSearch(keyword)
↓
ItemsRepository.customSearch(keyword)
↓
RetrofitInterface.customSearch(key, cx, q)
↓
Google Custom Search API
↓
JSON response
↓
Gson converts JSON to SearchResponse
↓
ViewModel updates mutableStateOf
↓
Compose recomposes and displays snippetkey = API key
cx = Search engine ID
q = keywordhttps://www.googleapis.com/customsearch/v1https://www.googleapis.com/customsearch/v1?key=...&cx=...&q=ParisInterface = API 请求说明书
Object = 真正创建网络请求工具的地方Repository 关心“数据从哪里来”
ViewModel 关心“UI 现在应该显示什么状态”输入框当前文字:可以放 remember
网络请求结果:更适合放 ViewModelUI -> ViewModel -> Repository -> DAO -> Room DatabaseUI -> ViewModel -> Repository -> Retrofit Interface -> Public APIRoom = 本地 SQLite 数据库
Retrofit = 远程网络 APIhttps://www.googleapis.com/customsearch/v1?key=API_KEY&cx=SEARCH_ENGINE_ID&q=keywordBASE_URL = https://www.googleapis.com/
@GET = customsearch/v1
最终 endpoint = https://www.googleapis.com/customsearch/v1implementation( "com.squareup.retrofit2:retrofit:3.0.0" ) implementation( "com.squareup.retrofit2:converter-gson:3.0.0" ) implementation( "com.google.code.gson:gson:2.13.2" ) implementation(libs.retrofit) implementation(libs.converter.gson) implementation(libs.gson) < uses-permission android:name= " android.permission.INTERNET " /> <? xml version= " 1.0 " encoding= " utf-8 " ?> < manifest xmlns:android= " http://schemas.android.com/apk/res/android " xmlns:tools= " http://schemas.android.com/tools " > < uses-permission android:name= " android.permission.INTERNET " /> < application android:allowBackup= " true " android:dataExtractionRules= " @xml/data_extraction_rules " android:fullBackupContent= " @xml/backup_rules " android:icon= " @mipmap/ic_launcher " android:label= " @string/app_name " android:roundIcon= " @mipmap/ic_launcher_round " android:supportsRtl= " true " android:theme= " @style/Theme.GoogleSearchLab " tools:targetApi= " 31 " > < activity android:name= " .MainActivity " android:exported= " true " > < intent-filter > < action android:name= " android.intent.action.MAIN " /> < category android:name= " android.intent.category.LAUNCHER " /> </ intent-filter > </ activity > </ application > </ manifest > < uses-permission android:name= " android.permission.INTERNET " /> { " items " : [ { " snippet " : " This is the search result snippet... " } ] } package com.example.googlesearchlab data class SearchResponse( val items: List<Items> = emptyList() ) package com.example.googlesearchlab data class Items( val snippet: String = "" ) package com.example.googlesearchlab import retrofit2.http.GET import retrofit2.http.Query interface RetrofitInterface { @GET( "customsearch/v1" ) suspend fun customSearch( @Query( "key" ) apiKey: String, @Query( "cx" ) searchEngineId: String, @Query( "q" ) keyword: String ): SearchResponse } package com.example.googlesearchlab import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory object RetrofitObject { private const val BASE_URL = "https://www.googleapis.com/" val retrofitService: RetrofitInterface by lazy { val retrofit = Retrofit.Builder() .baseUrl(BASE_URL) .addConverterFactory(GsonConverterFactory.create()) .build() retrofit.create(RetrofitInterface::class.java) } } package com.example.googlesearchlab class ItemsRepository { private val searchService = RetrofitObject.retrofitService private val API_KEY = "YOUR_API_KEY_HERE" private val SEARCH_ENGINE_ID = "YOUR_SEARCH_ENGINE_ID_HERE" suspend fun customSearch(keyword: String): SearchResponse { return searchService.customSearch( apiKey = API_KEY, searchEngineId = SEARCH_ENGINE_ID, keyword = keyword ) } } package com.example.googlesearchlab import android.util.Log import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.launch class RetrofitViewModel : ViewModel() { private val repository = ItemsRepository() val retrofitResponse = mutableStateOf(SearchResponse()) val isLoading = mutableStateOf(false) val errorMessage = mutableStateOf<String?>(null) fun customSearch(keyword: String) { if (keyword.isBlank()) { errorMessage. value = "Please enter a keyword" return } viewModelScope.launch { isLoading. value = true errorMessage. value = null try { retrofitResponse. value = repository.customSearch(keyword.trim()) } catch (e: Exception) { Log.e( "RetrofitViewModel" , "Response failed" , e) errorMessage. value = "Search failed. Check API key, cx, Internet permission, or Logcat." } finally { isLoading. value = false } } } } package com.example.googlesearchlab import com.example.googlesearchlab.ui.theme.GoogleSearchLabTheme package com.example.googlesearchlab import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.example.googlesearchlab.ui.theme.GoogleSearchLabTheme class MainActivity : ComponentActivity() { private val viewModel: RetrofitViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { GoogleSearchLabTheme { Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) { SearchResults(viewModel) } } } } } @Composable fun SearchResults(viewModel: RetrofitViewModel) { val itemsReturned by viewModel.retrofitResponse val isLoading by viewModel.isLoading val errorMessage by viewModel.errorMessage var keyword by remember { mutableStateOf( "" ) } Column(modifier = Modifier.padding(16.dp)) { Spacer(modifier = Modifier.height(32.dp)) OutlinedTextField( value = keyword, onValueChange = { keyword = it }, label = { Text( "Keyword" ) }, modifier = Modifier .fillMaxWidth() .padding(bottom = 8.dp), singleLine = true ) Button( onClick = { viewModel.customSearch(keyword) }, modifier = Modifier .fillMaxWidth() .height(48.dp), enabled = !isLoading ) { Text( if (isLoading) "Searching..." else "Search" ) } Spacer(modifier = Modifier.height(16.dp)) when { errorMessage != null -> { Text( text = errorMessage ?: "" , color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(top = 8.dp) ) } itemsReturned.items.isNotEmpty() -> { val resultSnippet = itemsReturned.items[0].snippet Text( text = "Results:$resultSnippet" , modifier = Modifier.padding(top = 8.dp) ) } else -> { Text( text = "No results yet" , modifier = Modifier.padding(top = 8.dp) ) } } } } Log.e( "RetrofitViewModel" , "Response failed" , e) curl " https://www.googleapis.com/customsearch/v1?key=YOUR_API_KEY_HERE&cx=YOUR_SEARCH_ENGINE_ID_HERE&q=Paris " private const val BASE_URL = "https://www.googleapis.com/" @Query( "q" ) keyword: String suspend fun customSearch(...): SearchResponse viewModelScope.launch { retrofitResponse. value = repository.customSearch(keyword) } val retrofitResponse = mutableStateOf(SearchResponse()) val itemsReturned by viewModel.retrofitResponse retrofitResponse. value = repository.customSearch(keyword) viewModelScope.launch { retrofitResponse. value = repository.customSearch(keyword) } private const val BASE_URL = "https://www.googleapis.com/" private const val BASE_URL = "https://www.googleapis.com" var keyword by remember { mutableStateOf( "" ) } import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue val itemsReturned by viewModel.retrofitResponse import androidx.compose.runtime.getValue @GET( "customsearch/v1" ) suspend fun customSearch(...): SearchResponse Retrofit.Builder() .baseUrl(BASE_URL) .addConverterFactory(GsonConverterFactory.create()) .build() var keyword by remember { mutableStateOf( "" ) } val retrofitResponse = mutableStateOf(SearchResponse()) itemsReturned.items.isNotEmpty() itemsReturned.items[0].snippet