blob: 8f583d92b452a91a811ad15211327bb806cec142 [file] [log] [blame]
/*
* Copyright (C) 2011 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.compat.annotation.UnsupportedAppUsage;
import android.content.ContentResolver;
import android.content.Context;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager.NameNotFoundException;
import android.content.res.XmlResourceParser;
import android.database.ContentObserver;
import android.os.Binder;
import android.os.Handler;
import android.os.Process;
import android.os.UserHandle;
import android.provider.Settings;
import android.telephony.PhoneNumberUtils;
import android.telephony.SmsManager;
import android.util.AtomicFile;
import android.util.Xml;
import com.android.internal.telephony.util.XmlUtils;
import com.android.internal.util.FastXmlSerializer;
import com.android.telephony.Rlog;
import org.xmlpull.v1.XmlPullParser;
import org.xmlpull.v1.XmlPullParserException;
import org.xmlpull.v1.XmlSerializer;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.FileReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.regex.Pattern;
/**
* Implement the per-application based SMS control, which limits the number of
* SMS/MMS messages an app can send in the checking period.
*
* This code was formerly part of {@link SMSDispatcher}, and has been moved
* into a separate class to support instantiation of multiple SMSDispatchers on
* dual-mode devices that require support for both 3GPP and 3GPP2 format messages.
*/
public class SmsUsageMonitor {
private static final String TAG = "SmsUsageMonitor";
private static final boolean DBG = false;
private static final boolean VDBG = false;
private static final String SHORT_CODE_PATH = "/data/misc/sms/codes";
/** Default checking period for SMS sent without user permission. */
private static final int DEFAULT_SMS_CHECK_PERIOD = 60000; // 1 minute
/** Default number of SMS sent in checking period without user permission. */
private static final int DEFAULT_SMS_MAX_COUNT = 30;
/** @hide */
public static int mergeShortCodeCategories(int type1, int type2) {
if (type1 > type2) return type1;
return type2;
}
/** Premium SMS permission for a new package (ask user when first premium SMS sent). */
public static final int PREMIUM_SMS_PERMISSION_UNKNOWN =
SmsManager.PREMIUM_SMS_CONSENT_UNKNOWN;
/** Default premium SMS permission (ask user for each premium SMS sent). */
public static final int PREMIUM_SMS_PERMISSION_ASK_USER =
SmsManager.PREMIUM_SMS_CONSENT_ASK_USER;
/** Premium SMS permission when the owner has denied the app from sending premium SMS. */
public static final int PREMIUM_SMS_PERMISSION_NEVER_ALLOW =
SmsManager.PREMIUM_SMS_CONSENT_NEVER_ALLOW;
/** Premium SMS permission when the owner has allowed the app to send premium SMS. */
public static final int PREMIUM_SMS_PERMISSION_ALWAYS_ALLOW =
SmsManager.PREMIUM_SMS_CONSENT_ALWAYS_ALLOW;
private final int mCheckPeriod;
private final int mMaxAllowed;
private final HashMap<String, ArrayList<Long>> mSmsStamp =
new HashMap<String, ArrayList<Long>>();
/** Context for retrieving regexes from XML resource. */
private final Context mContext;
/** Country code for the cached short code pattern matcher. */
private String mCurrentCountry;
/** Cached short code pattern matcher for {@link #mCurrentCountry}. */
private ShortCodePatternMatcher mCurrentPatternMatcher;
/** Notice when the enabled setting changes - can be changed through gservices */
private final AtomicBoolean mCheckEnabled = new AtomicBoolean(true);
/** Handler for responding to content observer updates. */
private final SettingsObserverHandler mSettingsObserverHandler;
/** File holding the patterns */
private final File mPatternFile = new File(SHORT_CODE_PATH);
/** Last modified time for pattern file */
private long mPatternFileLastModified = 0;
/** Directory for per-app SMS permission XML file. */
private static final String SMS_POLICY_FILE_DIRECTORY = "/data/misc/sms";
/** Per-app SMS permission XML filename. */
private static final String SMS_POLICY_FILE_NAME = "premium_sms_policy.xml";
/** XML tag for root element. */
private static final String TAG_SHORTCODES = "shortcodes";
/** XML tag for short code patterns for a specific country. */
private static final String TAG_SHORTCODE = "shortcode";
/** XML attribute for the country code. */
private static final String ATTR_COUNTRY = "country";
/** XML attribute for the short code regex pattern. */
private static final String ATTR_PATTERN = "pattern";
/** XML attribute for the premium short code regex pattern. */
private static final String ATTR_PREMIUM = "premium";
/** XML attribute for the free short code regex pattern. */
private static final String ATTR_FREE = "free";
/** XML attribute for the standard rate short code regex pattern. */
private static final String ATTR_STANDARD = "standard";
/** Stored copy of premium SMS package permissions. */
private AtomicFile mPolicyFile;
/** Loaded copy of premium SMS package permissions. */
private final HashMap<String, Integer> mPremiumSmsPolicy = new HashMap<String, Integer>();
/** XML tag for root element of premium SMS permissions. */
private static final String TAG_SMS_POLICY_BODY = "premium-sms-policy";
/** XML tag for a package. */
private static final String TAG_PACKAGE = "package";
/** XML attribute for the package name. */
private static final String ATTR_PACKAGE_NAME = "name";
/** XML attribute for the package's premium SMS permission (integer type). */
private static final String ATTR_PACKAGE_SMS_POLICY = "sms-policy";
/**
* SMS short code regex pattern matcher for a specific country.
*/
private static final class ShortCodePatternMatcher {
private final Pattern mShortCodePattern;
private final Pattern mPremiumShortCodePattern;
private final Pattern mFreeShortCodePattern;
private final Pattern mStandardShortCodePattern;
ShortCodePatternMatcher(String shortCodeRegex, String premiumShortCodeRegex,
String freeShortCodeRegex, String standardShortCodeRegex) {
mShortCodePattern = (shortCodeRegex != null ? Pattern.compile(shortCodeRegex) : null);
mPremiumShortCodePattern = (premiumShortCodeRegex != null ?
Pattern.compile(premiumShortCodeRegex) : null);
mFreeShortCodePattern = (freeShortCodeRegex != null ?
Pattern.compile(freeShortCodeRegex) : null);
mStandardShortCodePattern = (standardShortCodeRegex != null ?
Pattern.compile(standardShortCodeRegex) : null);
}
int getNumberCategory(String phoneNumber) {
if (mFreeShortCodePattern != null && mFreeShortCodePattern.matcher(phoneNumber)
.matches()) {
return SmsManager.SMS_CATEGORY_FREE_SHORT_CODE;
}
if (mStandardShortCodePattern != null && mStandardShortCodePattern.matcher(phoneNumber)
.matches()) {
return SmsManager.SMS_CATEGORY_STANDARD_SHORT_CODE;
}
if (mPremiumShortCodePattern != null && mPremiumShortCodePattern.matcher(phoneNumber)
.matches()) {
return SmsManager.SMS_CATEGORY_PREMIUM_SHORT_CODE;
}
if (mShortCodePattern != null && mShortCodePattern.matcher(phoneNumber).matches()) {
return SmsManager.SMS_CATEGORY_POSSIBLE_PREMIUM_SHORT_CODE;
}
return SmsManager.SMS_CATEGORY_NOT_SHORT_CODE;
}
}
/**
* Observe the secure setting for enable flag
*/
private static class SettingsObserver extends ContentObserver {
private final Context mContext;
private final AtomicBoolean mEnabled;
SettingsObserver(Handler handler, Context context, AtomicBoolean enabled) {
super(handler);
mContext = context;
mEnabled = enabled;
onChange(false);
}
@Override
public void onChange(boolean selfChange) {
mEnabled.set(Settings.Global.getInt(mContext.getContentResolver(),
Settings.Global.SMS_SHORT_CODE_CONFIRMATION, 1) != 0);
}
}
private static class SettingsObserverHandler extends Handler {
SettingsObserverHandler(Context context, AtomicBoolean enabled) {
ContentResolver resolver = context.getContentResolver();
ContentObserver globalObserver = new SettingsObserver(this, context, enabled);
resolver.registerContentObserver(Settings.Global.getUriFor(
Settings.Global.SMS_SHORT_CODE_CONFIRMATION), false, globalObserver);
}
}
/**
* Create SMS usage monitor.
* @param context the context to use to load resources and get TelephonyManager service
*/
@UnsupportedAppUsage
public SmsUsageMonitor(Context context) {
mContext = context;
ContentResolver resolver = context.getContentResolver();
mMaxAllowed = Settings.Global.getInt(resolver,
Settings.Global.SMS_OUTGOING_CHECK_MAX_COUNT,
DEFAULT_SMS_MAX_COUNT);
mCheckPeriod = Settings.Global.getInt(resolver,
Settings.Global.SMS_OUTGOING_CHECK_INTERVAL_MS,
DEFAULT_SMS_CHECK_PERIOD);
mSettingsObserverHandler = new SettingsObserverHandler(mContext, mCheckEnabled);
loadPremiumSmsPolicyDb();
}
/**
* Return a pattern matcher object for the specified country.
* @param country the country to search for
* @return a {@link ShortCodePatternMatcher} for the specified country, or null if not found
*/
private ShortCodePatternMatcher getPatternMatcherFromFile(String country) {
FileReader patternReader = null;
XmlPullParser parser = null;
try {
patternReader = new FileReader(mPatternFile);
parser = Xml.newPullParser();
parser.setInput(patternReader);
return getPatternMatcherFromXmlParser(parser, country);
} catch (FileNotFoundException e) {
Rlog.e(TAG, "Short Code Pattern File not found");
} catch (XmlPullParserException e) {
Rlog.e(TAG, "XML parser exception reading short code pattern file", e);
} finally {
mPatternFileLastModified = mPatternFile.lastModified();
if (patternReader != null) {
try {
patternReader.close();
} catch (IOException e) {}
}
}
return null;
}
private ShortCodePatternMatcher getPatternMatcherFromResource(String country) {
int id = com.android.internal.R.xml.sms_short_codes;
XmlResourceParser parser = null;
try {
parser = mContext.getResources().getXml(id);
return getPatternMatcherFromXmlParser(parser, country);
} finally {
if (parser != null) parser.close();
}
}
private ShortCodePatternMatcher getPatternMatcherFromXmlParser(XmlPullParser parser,
String country) {
try {
XmlUtils.beginDocument(parser, TAG_SHORTCODES);
while (true) {
XmlUtils.nextElement(parser);
String element = parser.getName();
if (element == null) {
Rlog.e(TAG, "Parsing pattern data found null");
break;
}
if (element.equals(TAG_SHORTCODE)) {
String currentCountry = parser.getAttributeValue(null, ATTR_COUNTRY);
if (VDBG) Rlog.d(TAG, "Found country " + currentCountry);
if (country.equals(currentCountry)) {
String pattern = parser.getAttributeValue(null, ATTR_PATTERN);
String premium = parser.getAttributeValue(null, ATTR_PREMIUM);
String free = parser.getAttributeValue(null, ATTR_FREE);
String standard = parser.getAttributeValue(null, ATTR_STANDARD);
return new ShortCodePatternMatcher(pattern, premium, free, standard);
}
} else {
Rlog.e(TAG, "Error: skipping unknown XML tag " + element);
}
}
} catch (XmlPullParserException e) {
Rlog.e(TAG, "XML parser exception reading short code patterns", e);
} catch (IOException e) {
Rlog.e(TAG, "I/O exception reading short code patterns", e);
}
if (DBG) Rlog.d(TAG, "Country (" + country + ") not found");
return null; // country not found
}
/** Clear the SMS application list for disposal. */
void dispose() {
mSmsStamp.clear();
}
/**
* Check to see if an application is allowed to send new SMS messages, and confirm with
* user if the send limit was reached or if a non-system app is potentially sending to a
* premium SMS short code or number.
*
* @param appName the package name of the app requesting to send an SMS
* @param smsWaiting the number of new messages desired to send
* @return true if application is allowed to send the requested number
* of new sms messages
*/
@UnsupportedAppUsage
public boolean check(String appName, int smsWaiting) {
synchronized (mSmsStamp) {
removeExpiredTimestamps();
ArrayList<Long> sentList = mSmsStamp.get(appName);
if (sentList == null) {
sentList = new ArrayList<Long>();
mSmsStamp.put(appName, sentList);
}
return isUnderLimit(sentList, smsWaiting);
}
}
/**
* Check if the destination is a possible premium short code.
* NOTE: the caller is expected to strip non-digits from the destination number with
* {@link PhoneNumberUtils#extractNetworkPortion} before calling this method.
* This happens in {@link SMSDispatcher#sendRawPdu} so that we use the same phone number
* for testing and in the user confirmation dialog if the user needs to confirm the number.
* This makes it difficult for malware to fool the user or the short code pattern matcher
* by using non-ASCII characters to make the number appear to be different from the real
* destination phone number.
*
* @param destAddress the destination address to test for possible short code
* @return {@link SmsManager#SMS_CATEGORY_FREE_SHORT_CODE},
* {@link SmsManager#SMS_CATEGORY_NOT_SHORT_CODE},
* {@link SmsManager#SMS_CATEGORY_POSSIBLE_PREMIUM_SHORT_CODE},
* {@link SmsManager#SMS_CATEGORY_STANDARD_SHORT_CODE}, or
* {@link SmsManager#SMS_CATEGORY_POSSIBLE_PREMIUM_SHORT_CODE}
*/
public int checkDestination(String destAddress, String countryIso) {
synchronized (mSettingsObserverHandler) {
// always allow emergency numbers
if (PhoneNumberUtils.isEmergencyNumber(destAddress, countryIso)) {
if (DBG) Rlog.d(TAG, "isEmergencyNumber");
return SmsManager.SMS_CATEGORY_NOT_SHORT_CODE;
}
// always allow if the feature is disabled
if (!mCheckEnabled.get()) {
if (DBG) Rlog.e(TAG, "check disabled");
return SmsManager.SMS_CATEGORY_NOT_SHORT_CODE;
}
if (countryIso != null) {
if (mCurrentCountry == null || !countryIso.equals(mCurrentCountry) ||
mPatternFile.lastModified() != mPatternFileLastModified) {
if (mPatternFile.exists()) {
if (DBG) Rlog.d(TAG, "Loading SMS Short Code patterns from file");
mCurrentPatternMatcher = getPatternMatcherFromFile(countryIso);
} else {
if (DBG) Rlog.d(TAG, "Loading SMS Short Code patterns from resource");
mCurrentPatternMatcher = getPatternMatcherFromResource(countryIso);
}
mCurrentCountry = countryIso;
}
}
if (mCurrentPatternMatcher != null) {
return mCurrentPatternMatcher.getNumberCategory(destAddress);
} else {
// Generic rule: numbers of 5 digits or less are considered potential short codes
Rlog.e(TAG, "No patterns for \"" + countryIso + "\": using generic short code rule");
if (destAddress.length() <= 5) {
return SmsManager.SMS_CATEGORY_POSSIBLE_PREMIUM_SHORT_CODE;
} else {
return SmsManager.SMS_CATEGORY_NOT_SHORT_CODE;
}
}
}
}
/**
* Load the premium SMS policy from an XML file.
* Based on code from NotificationManagerService.
*/
private void loadPremiumSmsPolicyDb() {
synchronized (mPremiumSmsPolicy) {
if (mPolicyFile == null) {
File dir = new File(SMS_POLICY_FILE_DIRECTORY);
mPolicyFile = new AtomicFile(new File(dir, SMS_POLICY_FILE_NAME));
mPremiumSmsPolicy.clear();
FileInputStream infile = null;
try {
infile = mPolicyFile.openRead();
final XmlPullParser parser = Xml.newPullParser();
parser.setInput(infile, StandardCharsets.UTF_8.name());
XmlUtils.beginDocument(parser, TAG_SMS_POLICY_BODY);
while (true) {
XmlUtils.nextElement(parser);
String element = parser.getName();
if (element == null) break;
if (element.equals(TAG_PACKAGE)) {
String packageName = parser.getAttributeValue(null, ATTR_PACKAGE_NAME);
String policy = parser.getAttributeValue(null, ATTR_PACKAGE_SMS_POLICY);
if (packageName == null) {
Rlog.e(TAG, "Error: missing package name attribute");
} else if (policy == null) {
Rlog.e(TAG, "Error: missing package policy attribute");
} else try {
mPremiumSmsPolicy.put(packageName, Integer.parseInt(policy));
} catch (NumberFormatException e) {
Rlog.e(TAG, "Error: non-numeric policy type " + policy);
}
} else {
Rlog.e(TAG, "Error: skipping unknown XML tag " + element);
}
}
} catch (FileNotFoundException e) {
// No data yet
} catch (IOException e) {
Rlog.e(TAG, "Unable to read premium SMS policy database", e);
} catch (NumberFormatException e) {
Rlog.e(TAG, "Unable to parse premium SMS policy database", e);
} catch (XmlPullParserException e) {
Rlog.e(TAG, "Unable to parse premium SMS policy database", e);
} finally {
if (infile != null) {
try {
infile.close();
} catch (IOException ignored) {
}
}
}
}
}
}
/**
* Persist the premium SMS policy to an XML file.
* Based on code from NotificationManagerService.
*/
private void writePremiumSmsPolicyDb() {
synchronized (mPremiumSmsPolicy) {
FileOutputStream outfile = null;
try {
outfile = mPolicyFile.startWrite();
XmlSerializer out = new FastXmlSerializer();
out.setOutput(outfile, StandardCharsets.UTF_8.name());
out.startDocument(null, true);
out.startTag(null, TAG_SMS_POLICY_BODY);
for (Map.Entry<String, Integer> policy : mPremiumSmsPolicy.entrySet()) {
out.startTag(null, TAG_PACKAGE);
out.attribute(null, ATTR_PACKAGE_NAME, policy.getKey());
out.attribute(null, ATTR_PACKAGE_SMS_POLICY, policy.getValue().toString());
out.endTag(null, TAG_PACKAGE);
}
out.endTag(null, TAG_SMS_POLICY_BODY);
out.endDocument();
mPolicyFile.finishWrite(outfile);
} catch (IOException e) {
Rlog.e(TAG, "Unable to write premium SMS policy database", e);
if (outfile != null) {
mPolicyFile.failWrite(outfile);
}
}
}
}
/**
* Returns the premium SMS permission for the specified package. If the package has never
* been seen before, the default {@link #PREMIUM_SMS_PERMISSION_UNKNOWN}
* will be returned.
* @param packageName the name of the package to query permission
* @return one of {@link #PREMIUM_SMS_PERMISSION_UNKNOWN},
* {@link #PREMIUM_SMS_PERMISSION_ASK_USER},
* {@link #PREMIUM_SMS_PERMISSION_NEVER_ALLOW}, or
* {@link #PREMIUM_SMS_PERMISSION_ALWAYS_ALLOW}
* @throws SecurityException if the caller is not a system process
*/
public int getPremiumSmsPermission(String packageName) {
checkCallerIsSystemOrPhoneOrSameApp(packageName);
synchronized (mPremiumSmsPolicy) {
Integer policy = mPremiumSmsPolicy.get(packageName);
if (policy == null) {
return PREMIUM_SMS_PERMISSION_UNKNOWN;
} else {
return policy;
}
}
}
/**
* Sets the premium SMS permission for the specified package and save the value asynchronously
* to persistent storage.
* @param packageName the name of the package to set permission
* @param permission one of {@link #PREMIUM_SMS_PERMISSION_ASK_USER},
* {@link #PREMIUM_SMS_PERMISSION_NEVER_ALLOW}, or
* {@link #PREMIUM_SMS_PERMISSION_ALWAYS_ALLOW}
* @throws SecurityException if the caller is not a system process
*/
public void setPremiumSmsPermission(String packageName, int permission) {
checkCallerIsSystemOrPhoneApp();
if (permission < PREMIUM_SMS_PERMISSION_ASK_USER
|| permission > PREMIUM_SMS_PERMISSION_ALWAYS_ALLOW) {
throw new IllegalArgumentException("invalid SMS permission type " + permission);
}
synchronized (mPremiumSmsPolicy) {
mPremiumSmsPolicy.put(packageName, permission);
}
// write policy file in the background
new Thread(new Runnable() {
@Override
public void run() {
writePremiumSmsPolicyDb();
}
}).start();
}
private void checkCallerIsSystemOrPhoneOrSameApp(String pkg) {
int uid = Binder.getCallingUid();
int appId = UserHandle.getAppId(uid);
if (appId == Process.SYSTEM_UID || appId == Process.PHONE_UID || uid == 0) {
return;
}
try {
ApplicationInfo ai = mContext.getPackageManager().getApplicationInfoAsUser(
pkg, 0, UserHandle.getUserHandleForUid(uid));
if (UserHandle.getAppId(ai.uid) != UserHandle.getAppId(uid)) {
throw new SecurityException("Calling uid " + uid + " gave package"
+ pkg + " which is owned by uid " + ai.uid);
}
} catch (NameNotFoundException ex) {
throw new SecurityException("Unknown package " + pkg + "\n" + ex);
}
}
private static void checkCallerIsSystemOrPhoneApp() {
int uid = Binder.getCallingUid();
int appId = UserHandle.getAppId(uid);
if (appId == Process.SYSTEM_UID || appId == Process.PHONE_UID || uid == 0) {
return;
}
throw new SecurityException("Disallowed call for uid " + uid);
}
/**
* Remove keys containing only old timestamps. This can happen if an SMS app is used
* to send messages and then uninstalled.
*/
private void removeExpiredTimestamps() {
long beginCheckPeriod = System.currentTimeMillis() - mCheckPeriod;
synchronized (mSmsStamp) {
Iterator<Map.Entry<String, ArrayList<Long>>> iter = mSmsStamp.entrySet().iterator();
while (iter.hasNext()) {
Map.Entry<String, ArrayList<Long>> entry = iter.next();
ArrayList<Long> oldList = entry.getValue();
if (oldList.isEmpty() || oldList.get(oldList.size() - 1) < beginCheckPeriod) {
iter.remove();
}
}
}
}
private boolean isUnderLimit(ArrayList<Long> sent, int smsWaiting) {
Long ct = System.currentTimeMillis();
long beginCheckPeriod = ct - mCheckPeriod;
if (VDBG) log("SMS send size=" + sent.size() + " time=" + ct);
while (!sent.isEmpty() && sent.get(0) < beginCheckPeriod) {
sent.remove(0);
}
if ((sent.size() + smsWaiting) <= mMaxAllowed) {
for (int i = 0; i < smsWaiting; i++ ) {
sent.add(ct);
}
return true;
}
return false;
}
private static void log(String msg) {
Rlog.d(TAG, msg);
}
}