| /* |
| * Copyright (C) 2016 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.internal.telephony; |
| |
| import android.annotation.NonNull; |
| import android.annotation.Nullable; |
| import android.app.AppOpsManager; |
| import android.app.PendingIntent; |
| import android.content.Context; |
| import android.content.Intent; |
| import android.os.Binder; |
| import android.provider.Telephony.Sms.Intents; |
| import android.telephony.SmsManager; |
| import android.telephony.SmsMessage; |
| import android.telephony.SubscriptionManager; |
| import android.text.TextUtils; |
| import android.util.ArrayMap; |
| import android.util.Base64; |
| import android.util.Log; |
| |
| import com.android.internal.annotations.GuardedBy; |
| import com.android.internal.util.Preconditions; |
| |
| import java.security.SecureRandom; |
| import java.util.Map; |
| import java.util.Set; |
| import java.util.concurrent.TimeUnit; |
| |
| |
| /** |
| * Manager for app specific SMS requests. This can be used to implement SMS based |
| * communication channels (e.g. for SMS based phone number verification) without needing the |
| * {@link Manifest.permission#RECEIVE_SMS} permission. |
| * |
| * {@link #createAppSpecificSmsRequest} allows an application to provide a {@link PendingIntent} |
| * that is triggered when an incoming SMS is received that contains the provided token. |
| */ |
| public class AppSmsManager { |
| private static final String LOG_TAG = "AppSmsManager"; |
| |
| private static final long TIMEOUT_MILLIS = TimeUnit.MINUTES.toMillis(5); |
| private final SecureRandom mRandom; |
| private final Context mContext; |
| private final Object mLock = new Object(); |
| |
| @GuardedBy("mLock") |
| private final Map<String, AppRequestInfo> mTokenMap; |
| @GuardedBy("mLock") |
| private final Map<String, AppRequestInfo> mPackageMap; |
| |
| public AppSmsManager(Context context) { |
| mRandom = new SecureRandom(); |
| mTokenMap = new ArrayMap<>(); |
| mPackageMap = new ArrayMap<>(); |
| mContext = context; |
| } |
| |
| /** |
| * Create an app specific incoming SMS request for the the calling package. |
| * |
| * This method returns a token that if included in a subsequent incoming SMS message the |
| * {@link Intents.SMS_RECEIVED_ACTION} intent will be delivered only to the calling package and |
| * will not require the application have the {@link Manifest.permission#RECEIVE_SMS} permission. |
| * |
| * An app can only have one request at a time, if the app already has a request it will be |
| * dropped and the new one will be added. |
| * |
| * @return Token to include in an SMS to have it delivered directly to the app. |
| */ |
| public String createAppSpecificSmsToken(String callingPkg, PendingIntent intent) { |
| // Check calling uid matches callingpkg. |
| AppOpsManager appOps = (AppOpsManager) mContext.getSystemService(Context.APP_OPS_SERVICE); |
| appOps.checkPackage(Binder.getCallingUid(), callingPkg); |
| |
| // Generate a nonce to store the request under. |
| String token = generateNonce(); |
| synchronized (mLock) { |
| // Only allow one request in flight from a package. |
| if (mPackageMap.containsKey(callingPkg)) { |
| removeRequestLocked(mPackageMap.get(callingPkg)); |
| } |
| // Store state. |
| AppRequestInfo info = new AppRequestInfo(callingPkg, intent, token); |
| addRequestLocked(info); |
| } |
| return token; |
| } |
| |
| /** |
| * Create an app specific incoming SMS request for the the calling package. |
| * |
| * This method returns a token that if included in a subsequent incoming SMS message the |
| * {@link Intents.SMS_RECEIVED_ACTION} intent will be delivered only to the calling package and |
| * will not require the application have the {@link Manifest.permission#RECEIVE_SMS} permission. |
| * |
| * An app can only have one request at a time, if the app already has a request it will be |
| * dropped and the new one will be added. |
| * |
| * @return Token to include in an SMS to have it delivered directly to the app. |
| */ |
| public String createAppSpecificSmsTokenWithPackageInfo(int subId, |
| @NonNull String callingPackageName, |
| @Nullable String prefixes, |
| @NonNull PendingIntent intent) { |
| Preconditions.checkStringNotEmpty(callingPackageName, |
| "callingPackageName cannot be null or empty."); |
| Preconditions.checkNotNull(intent, "intent cannot be null"); |
| // Check calling uid matches callingpkg. |
| AppOpsManager appOps = (AppOpsManager) mContext.getSystemService(Context.APP_OPS_SERVICE); |
| appOps.checkPackage(Binder.getCallingUid(), callingPackageName); |
| |
| // Generate a token to store the request under. |
| String token = PackageBasedTokenUtil.generateToken(mContext, callingPackageName); |
| if (token != null) { |
| synchronized (mLock) { |
| // Only allow one request in flight from a package. |
| if (mPackageMap.containsKey(callingPackageName)) { |
| removeRequestLocked(mPackageMap.get(callingPackageName)); |
| } |
| // Store state. |
| AppRequestInfo info = new AppRequestInfo( |
| callingPackageName, intent, token, prefixes, subId, true); |
| addRequestLocked(info); |
| } |
| } |
| return token; |
| } |
| |
| /** |
| * Handle an incoming SMS_DELIVER_ACTION intent if it is an app-only SMS. |
| */ |
| public boolean handleSmsReceivedIntent(Intent intent) { |
| // Sanity check the action. |
| if (intent.getAction() != Intents.SMS_DELIVER_ACTION) { |
| Log.wtf(LOG_TAG, "Got intent with incorrect action: " + intent.getAction()); |
| return false; |
| } |
| |
| synchronized (mLock) { |
| removeExpiredTokenLocked(); |
| |
| String message = extractMessage(intent); |
| if (TextUtils.isEmpty(message)) { |
| return false; |
| } |
| |
| AppRequestInfo info = findAppRequestInfoSmsIntentLocked(message); |
| if (info == null) { |
| // The message didn't contain a token -- nothing to do. |
| return false; |
| } |
| |
| try { |
| Intent fillIn = new Intent() |
| .putExtras(intent.getExtras()) |
| .putExtra(SmsManager.EXTRA_STATUS, SmsManager.RESULT_STATUS_SUCCESS) |
| .putExtra(SmsManager.EXTRA_SMS_MESSAGE, message) |
| .putExtra(SmsManager.EXTRA_SIM_SUBSCRIPTION_ID, info.subId) |
| .addFlags(Intent.FLAG_RECEIVER_VISIBLE_TO_INSTANT_APPS); |
| |
| info.pendingIntent.send(mContext, 0, fillIn); |
| } catch (PendingIntent.CanceledException e) { |
| // The pending intent is canceled, send this SMS as normal. |
| removeRequestLocked(info); |
| return false; |
| } |
| |
| removeRequestLocked(info); |
| return true; |
| } |
| } |
| |
| private void removeExpiredTokenLocked() { |
| final long currentTimeMillis = System.currentTimeMillis(); |
| |
| final Set<String> keySet = mTokenMap.keySet(); |
| for (String token : keySet) { |
| AppRequestInfo request = mTokenMap.get(token); |
| if (request.packageBasedToken |
| && (currentTimeMillis - TIMEOUT_MILLIS > request.timestamp)) { |
| // Send the provided intent with SMS retriever status |
| try { |
| Intent fillIn = new Intent() |
| .putExtra(SmsManager.EXTRA_STATUS, |
| SmsManager.RESULT_STATUS_TIMEOUT) |
| .addFlags(Intent.FLAG_RECEIVER_VISIBLE_TO_INSTANT_APPS); |
| request.pendingIntent.send(mContext, 0, fillIn); |
| } catch (PendingIntent.CanceledException e) { |
| // do nothing |
| } |
| |
| removeRequestLocked(request); |
| } |
| } |
| } |
| |
| private String extractMessage(Intent intent) { |
| SmsMessage[] messages = Intents.getMessagesFromIntent(intent); |
| if (messages == null) { |
| return null; |
| } |
| StringBuilder fullMessageBuilder = new StringBuilder(); |
| for (SmsMessage message : messages) { |
| if (message == null || message.getMessageBody() == null) { |
| continue; |
| } |
| fullMessageBuilder.append(message.getMessageBody()); |
| } |
| |
| return fullMessageBuilder.toString(); |
| } |
| |
| private AppRequestInfo findAppRequestInfoSmsIntentLocked(String fullMessage) { |
| // Look for any tokens in the full message. |
| for (String token : mTokenMap.keySet()) { |
| if (fullMessage.trim().contains(token) && hasPrefix(token, fullMessage)) { |
| return mTokenMap.get(token); |
| } |
| } |
| return null; |
| } |
| |
| private String generateNonce() { |
| byte[] bytes = new byte[8]; |
| mRandom.nextBytes(bytes); |
| return Base64.encodeToString(bytes, Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING); |
| } |
| |
| private boolean hasPrefix(String token, String message) { |
| AppRequestInfo request = mTokenMap.get(token); |
| if (TextUtils.isEmpty(request.prefixes)) { |
| return true; |
| } |
| |
| String[] prefixes = request.prefixes.split(SmsManager.REGEX_PREFIX_DELIMITER); |
| for (String prefix : prefixes) { |
| if (message.startsWith(prefix)) { |
| return true; |
| } |
| } |
| return false; |
| } |
| |
| private void removeRequestLocked(AppRequestInfo info) { |
| mTokenMap.remove(info.token); |
| mPackageMap.remove(info.packageName); |
| } |
| |
| private void addRequestLocked(AppRequestInfo info) { |
| mTokenMap.put(info.token, info); |
| mPackageMap.put(info.packageName, info); |
| } |
| |
| private final class AppRequestInfo { |
| public final String packageName; |
| public final PendingIntent pendingIntent; |
| public final String token; |
| public final long timestamp; |
| public final String prefixes; |
| public final int subId; |
| public final boolean packageBasedToken; |
| |
| AppRequestInfo(String packageName, PendingIntent pendingIntent, String token) { |
| this(packageName, pendingIntent, token, null, |
| SubscriptionManager.INVALID_SUBSCRIPTION_ID, false); |
| } |
| |
| AppRequestInfo(String packageName, PendingIntent pendingIntent, String token, |
| String prefixes, int subId, boolean packageBasedToken) { |
| this.packageName = packageName; |
| this.pendingIntent = pendingIntent; |
| this.token = token; |
| this.timestamp = System.currentTimeMillis(); |
| this.prefixes = prefixes; |
| this.subId = subId; |
| this.packageBasedToken = packageBasedToken; |
| } |
| } |
| |
| } |