1. Android 发送信息完整链路:从 SmsManager 到 FCM 推送的凭证统一管理
Android 端发送信息这件事,看起来只是调一个sendTextMessage,真到项目里往往会拆成两条完全不同的通道:一条走运营商短信(SmsManager),一条走云端推送(FCM)。前者依赖设备 SIM 卡和SEND_SMS权限,后者依赖 Google 服务、服务端凭证和一套 HTTP 接口。两条链路的技术栈、调试方式、失败表现都不一样,最麻烦的是凭证散落在三四个地方:短信网关的 Key、FCM 服务账号 JSON、后端接口 Token,改一次配置要翻半天。
这篇就按「Android 实例-发送信息」这个场景,把两条通道串起来跑一遍。目标很具体:在真机上发出一条短信,同时通过统一 Key 通道触发一条推送,并且把多服务凭证收敛到一处管理。适合已经写过 Android 基础页面、想搞清楚发送信息完整链路的同学,也适合被多套 Key 折腾过的后端联调同学。
核心检索词先摆出来:Android 发送信息、SmsManager 发短信、FCM 推送、统一 Key 管理多服务凭证。这几个词会贯穿全文。
先说清楚两条通道的本质差异。SmsManager 是 Android 系统 API,代码跑在设备上,短信直接从 SIM 卡出去,不经过任何第三方服务器,所以它没有「Key」的概念,只有权限和运营商限制。FCM 推送相反,消息从你的服务端发出,经过 FCM 服务投递到设备,服务端需要凭证(Service Account 或 OAuth Token),设备端需要注册 Token。把这两者放进同一个 App,凭证管理就成了绕不开的问题。
我试过的做法是:短信通道保持系统 API 直发,推送通道和后续要扩展的短信网关、邮件、验证码服务,统一走一个 API 网关来管理 Key。这样设备端只关心「发短信」和「请求推送」两个动作,凭证的轮换、限额、审计都放在网关侧。下面按这个思路一步步落地。
先看整体结构。App 里有两个入口:一个「发送短信」按钮走 SmsManager,一个「发送推送」按钮走 HTTP 请求到统一网关,由网关去调 FCM。这样设备端不需要内置 FCM 服务账号(那玩意儿放客户端是安全事故),只需要一个网关的 API Key。这个 Key 就是我们要统一管理的东西。
你可能会问,为什么不直接在 App 里集成 FCM SDK 收推送?收推送确实要集成,但「发推送」这个动作必须由服务端发起,客户端只能接收。所以本文的推送部分,重点在「如何用统一 Key 从客户端触发服务端发送」,而不是客户端自己发 FCM。这个区分很关键,很多新手会混淆。
接下来进入实操。整个流程分四块:环境与依赖、权限声明、短信发送代码、推送触发代码,最后是验证和排障。每一块都给可复制的片段,你照着改包名就能跑。
2. TaoToken 前置准备:统一 Key 与 API 通道配置
在写代码之前,先把凭证这层理清楚。我们要用 TaoToken 作为统一 API 通道,把推送触发、后续可能加的短信网关、模型调用等服务的 Key 收敛到一处。这样 App 里只需要配置一个 Base URL 和一个 API Key,换服务不用改客户端代码。
先拿 Key。打开 https://taotoken.net/api-keys ,登录后创建一个 API Key,复制保存。这个 Key 就是客户端请求网关时带的凭证。注意它只显示一次,丢了就重新建。
拿到 Key 之后,你需要知道请求打到哪个地址。TaoToken 的 API 入口是 https://taotoken.net/api ,所有兼容接口都挂在这个 Base URL 下。客户端配置里填这个地址,不要带多余的路径后缀,具体端点由代码里的相对路径决定。
如果你后面要接 Claude Code 或者做长期编码 Agent,可以了解下 Coding Plan:https://taotoken.net/coding-plan 。本文的推送触发用普通 API 调用就够了,不需要额外套餐。
模型和端点信息可以在文档里查:https://taotoken.net/doc 。推送触发本质是一次 HTTP POST,端点路径和参数格式文档里都有。建议先把文档里「对话/请求」那部分的请求体格式看一眼,因为下面代码里的 JSON 结构要和它对齐。
这里要强调一个安全点:API Key 不要硬编码在 Java 源码里提交到 Git。正确做法是放在local.properties或BuildConfig里,通过 Gradle 注入。下面配置章节会给具体写法。把 Key 写死在MainActivity.java里,一旦仓库公开就是事故。
统一 Key 的好处在这里体现:短信通道不需要 Key,推送通道用一个 Key,将来加验证码服务还是这个 Key,客户端只维护一份配置。轮换时改一处,所有通道生效。这就是「统一 Key 管理多服务凭证」的实际价值,不是概念,是少改代码。
配置完成后,你手里应该有三样东西:Base URL(https://taotoken.net/api )、API Key(sk- 开头那串)、以及要调用的端点路径。三件套齐了再往下写代码,否则调不通会浪费很多时间在猜问题上。
顺便提一句,如果你更想先在网页上验证 Key 是否可用,可以打开模型对话页面 https://taotoken.net/chat 发一条消息试试。能正常返回,说明 Key 和账户状态没问题,再去写 Android 代码心里有底。
3. 可复制配置:Gradle 依赖、Manifest 权限与 Key 注入
这一节全是可直接复制的配置。按顺序改,别跳步。
先改app/build.gradle。短信部分用系统 API 不需要额外依赖,推送触发用 OkHttp 发 HTTP 请求,JSON 解析用 org.json(Android 自带)。加上 OkHttp:
dependencies { implementation 'com.squareup.okhttp3:okhttp:4.12.0' implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'com.google.android.material:material:1.11.0' }然后在同一个文件的android块里,从local.properties读取 Key 并注入 BuildConfig:
android { compileSdk 34 defaultConfig { applicationId "com.example.messagesender" minSdk 21 targetSdk 34 versionCode 1 versionName "1.0" // 从 local.properties 读取,避免硬编码 def apiKey = project.findProperty("TAOTOKEN_API_KEY") ?: "" buildConfigField "String", "TAOTOKEN_API_KEY", "\"${apiKey}\"" buildConfigField "String", "TAOTOKEN_BASE_URL", "\"https://taotoken.net/api\"" } buildFeatures { buildConfig true } }在项目根目录的local.properties里加一行(这个文件默认在 .gitignore 里,安全):
TAOTOKEN_API_KEY=sk-你的实际Key接着改AndroidManifest.xml,声明短信权限和网络权限:
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example.messagesender"> <uses-permission android:name="android.permission.SEND_SMS" /> <uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <application android:allowBackup="true" android:label="MessageSender" android:theme="@style/Theme.AppCompat"> <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>注意SEND_SMS是危险权限,Android 6.0 以上必须运行时申请,光在 Manifest 里声明不够。下面代码里会处理。
布局文件activity_main.xml沿用经典结构,两个输入框加两个按钮:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:padding="16dp"> <EditText android:id="@+id/editText1" android:layout_width="match_parent" android:layout_height="wrap_content" android:hint="请输入手机号" /> <EditText android:id="@+id/editText2" android:layout_width="match_parent" android:layout_height="wrap_content" android:hint="请输入短信内容" android:minLines="3" android:gravity="top" /> <Button android:id="@+id/button1" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="发送短信" /> <Button android:id="@+id/button2" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="发送推送" /> </LinearLayout>到这里配置层就齐了。三件套对应关系:Base URL 是BuildConfig.TAOTOKEN_BASE_URL,Key 是BuildConfig.TAOTOKEN_API_KEY,Model ID 在推送请求体里指定(下面代码里用model字段)。这三样在客户端配置里必须同时存在,缺一个请求就会失败。
注意:
local.properties不要提交到版本库。团队协作时每个人本地配自己的 Key,或者用 CI 的环境变量注入。这是凭证管理的基本纪律。
4. 发送信息验证:SmsManager 发短信与统一 Key 触发推送
现在写核心逻辑。MainActivity.java里做两件事:短信直发、推送触发。先看完整代码,再拆解关键点。
package com.example.messagesender; import android.Manifest; import android.content.pm.PackageManager; import android.os.Bundle; import android.telephony.SmsManager; import android.view.View; import android.widget.Button; import android.widget.EditText; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.appcompat.app.AppCompatActivity; import androidx.core.app.ActivityCompat; import androidx.core.content.ContextCompat; import java.io.IOException; import java.util.ArrayList; import okhttp3.Call; import okhttp3.Callback; import okhttp3.MediaType; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.RequestBody; import okhttp3.Response; import org.json.JSONArray; import org.json.JSONObject; public class MainActivity extends AppCompatActivity { private static final int SMS_PERMISSION_CODE = 100; private EditText numberText; private EditText contentText; private final OkHttpClient client = new OkHttpClient(); @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); numberText = findViewById(R.id.editText1); contentText = findViewById(R.id.editText2); Button smsButton = findViewById(R.id.button1); Button pushButton = findViewById(R.id.button2); smsButton.setOnClickListener(v -> { if (checkSmsPermission()) { sendSms(); } else { ActivityCompat.requestPermissions( MainActivity.this, new String[]{Manifest.permission.SEND_SMS}, SMS_PERMISSION_CODE); } }); pushButton.setOnClickListener(v -> sendPush()); } private boolean checkSmsPermission() { return ContextCompat.checkSelfPermission(this, Manifest.permission.SEND_SMS) == PackageManager.PERMISSION_GRANTED; } @Override public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { super.onRequestPermissionsResult(requestCode, permissions, grantResults); if (requestCode == SMS_PERMISSION_CODE && grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { sendSms(); } else { Toast.makeText(this, "未授予短信权限", Toast.LENGTH_SHORT).show(); } } private void sendSms() { String number = numberText.getText().toString().trim(); String content = contentText.getText().toString().trim(); if (number.isEmpty() || content.isEmpty()) { Toast.makeText(this, "手机号或内容为空", Toast.LENGTH_SHORT).show(); return; } try { SmsManager manager = SmsManager.getDefault(); ArrayList<String> parts = manager.divideMessage(content); for (String part : parts) { manager.sendTextMessage(number, null, part, null, null); } Toast.makeText(this, "短信已提交发送", Toast.LENGTH_SHORT).show(); } catch (Exception e) { Toast.makeText(this, "发送失败: " + e.getMessage(), Toast.LENGTH_LONG).show(); } } private void sendPush() { String content = contentText.getText().toString().trim(); if (content.isEmpty()) { Toast.makeText(this, "推送内容为空", Toast.LENGTH_SHORT).show(); return; } try { JSONObject payload = new JSONObject(); payload.put("model", "gpt-4o-mini"); JSONArray messages = new JSONArray(); JSONObject msg = new JSONObject(); msg.put("role", "user"); msg.put("content", "请把这条通知精简成一句话推送文案:" + content); messages.put(msg); payload.put("messages", messages); MediaType JSON = MediaType.parse("application/json; charset=utf-8"); RequestBody body = RequestBody.create(payload.toString(), JSON); Request request = new Request.Builder() .url(BuildConfig.TAOTOKEN_BASE_URL + "/v1/chat/completions") .addHeader("Authorization", "Bearer " + BuildConfig.TAOTOKEN_API_KEY) .addHeader("Content-Type", "application/json") .post(body) .build(); client.newCall(request).enqueue(new Callback() { @Override public void onFailure(@NonNull Call call, @NonNull IOException e) { runOnUiThread(() -> Toast.makeText(MainActivity.this, "推送请求失败: " + e.getMessage(), Toast.LENGTH_LONG).show()); } @Override public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException { String respBody = response.body() != null ? response.body().string() : ""; runOnUiThread(() -> { if (response.isSuccessful()) { Toast.makeText(MainActivity.this, "推送触发成功", Toast.LENGTH_SHORT).show(); } else { Toast.makeText(MainActivity.this, "推送失败 code=" + response.code() + " body=" + respBody, Toast.LENGTH_LONG).show(); } }); } }); } catch (Exception e) { Toast.makeText(this, "构造请求异常: " + e.getMessage(), Toast.LENGTH_LONG).show(); } } }几个关键点拆开说。
短信部分,SmsManager.getDefault()拿到管理器,divideMessage按 70 字符(中文)或 160 字符(英文)自动拆分长短信,然后逐条sendTextMessage。第四个参数是发送状态 PendingIntent,第五个是送达状态 PendingIntent,这里传 null 表示不监听。生产环境建议传 PendingIntent 做状态回调,否则你只知道「提交了」,不知道「发出去了没」。
推送部分,请求打到BuildConfig.TAOTOKEN_BASE_URL + "/v1/chat/completions",Header 里带Authorization: Bearer <Key>,Body 是标准 chat 格式。这里用模型把通知内容精简成推送文案,实际项目里你可以把这一步换成直接调 FCM 的端点,或者调你自己的后端。关键是凭证走同一套 Base URL + Key。
三件套在这里的体现:Base URL 来自BuildConfig.TAOTOKEN_BASE_URL,Key 来自BuildConfig.TAOTOKEN_API_KEY,Model ID 是请求体里的"model": "gpt-4o-mini"。三个都在,请求才成立。
跑之前确认真机插了 SIM 卡、有信号、短信余额够。模拟器发不了真实短信,必须真机。推送部分需要网络,Wi-Fi 或流量都行。
5. 本篇常见错排查:401、local proxy failed 与 reading choices 报错
这一节按真实报错来。你跑上面代码,大概率会遇到下面几个。
401 Unauthorized。表现是推送按钮点了,Toast 显示推送失败 code=401。原因通常是 Key 没注入成功或格式不对。排查顺序:先看BuildConfig.TAOTOKEN_API_KEY是不是空字符串,如果是,说明local.properties里的TAOTOKEN_API_KEY没被 Gradle 读到,检查属性名拼写和文件位置(必须在项目根目录,不是 app 目录)。再看 Key 有没有多余空格,复制时容易带上换行。最后确认 Header 格式是Bearer sk-xxx,中间一个空格,别写成Bearer: sk-xxx。
local proxy failed。这个报错一般出现在你本地配了抓包工具或代理,OkHttp 走了系统代理但代理没起来。表现是onFailure里收到Failed to connect to /127.0.0.1:8888之类。解决:要么关掉代理,要么在 OkHttpClient 里显式设置proxy(Proxy.NO_PROXY)。代码里加一行:
private final OkHttpClient client = new OkHttpClient.Builder() .proxy(java.net.Proxy.NO_PROXY) .build();注意,这里说的代理是本地调试用的抓包代理,不是网络访问层面的东西,别混淆。
reading choices 报错。如果你把请求体改成了别的格式,或者端点路径写错,返回的 JSON 结构对不上,解析时可能报No value for choices或类似。表现是response.isSuccessful()为 true 但后续解析崩了。排查:先打印respBody看实际返回结构,确认choices数组存在。如果返回的是错误对象(比如{"error": {...}}),说明请求参数有问题,检查model字段是否是有效模型 ID。
OAuth 相关报错。如果你用的是需要 OAuth 的端点而不是 API Key,可能会看到invalid_grant或OAuth token expired。本文用的是 API Key 方式,不涉及 OAuth 流程。如果你确实要接 OAuth 类服务,凭证刷新逻辑要单独处理,别和 API Key 混在一起。
短信发不出去但没报错。sendTextMessage不抛异常不代表发出去了。常见原因:手机号格式带空格或+86前缀处理不当、短信中心号码配置错误、被运营商拦截。排查:先用系统短信 App 手动发一条确认卡正常,再回来测代码。长短信拆分后如果某条失败,整条就断了,建议加 PendingIntent 监听每条状态。
权限申请后仍报 SecurityException。检查是不是在onCreate里直接调了sendSms而没走权限检查。Android 6.0 以上必须运行时申请,Manifest 声明只是前提。上面代码里的checkSmsPermission+requestPermissions流程要完整走一遍。
对照这几个报错,基本能覆盖 90% 的首次运行问题。剩下的多半是网络环境或账户状态,去 https://taotoken.net/api-keys 确认 Key 有效、余额正常。
6. 从实例到工程:把统一 Key 通道用起来
跑通上面这条链路之后,你手里其实有了一个可复用的模式:设备端只维护一份 Base URL + Key 配置,所有需要服务端能力的动作都通过这个通道走。短信是系统 API 直发,推送、验证码、内容处理都走统一网关。加新服务时,客户端代码几乎不用动,改的是网关侧的路由。
如果你后面要做更复杂的编码任务或者 Agent 类功能,可以看下 Coding Plan:https://taotoken.net/coding-plan ,它适合长期、高频的调用场景。日常调试和验证模型连通性,用模型对话页面 https://taotoken.net/chat 就够了。接入文档在 https://taotoken.net/doc ,端点、参数、错误码都在里面,遇到不确定的请求格式先查文档再改代码。
最后给一个实用技巧:把TAOTOKEN_BASE_URL和TAOTOKEN_API_KEY的读取逻辑封装成一个Config类,所有网络请求统一从这里取。这样将来换环境(测试/生产)只改一处,也方便做 Key 轮换。凭证管理这件事,散着放迟早出问题,收拢到一处才是长久做法。
代码跑起来后,真机上应该能看到:点「发送短信」对方收到短信,点「发送推送」返回成功并触发服务端动作。两条通道,一套 Key,这就是本文要交付的完整链路。