1/*
2 * Copyright (C) 2017 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.timezone.data;
18
19import com.android.timezone.distro.DistroException;
20import com.android.timezone.distro.DistroVersion;
21import com.android.timezone.distro.TimeZoneDistro;
22
23import android.content.ContentProvider;
24import android.content.ContentValues;
25import android.content.Context;
26import android.content.pm.PackageManager;
27import android.content.pm.ProviderInfo;
28import android.content.res.AssetManager;
29import android.database.AbstractCursor;
30import android.database.Cursor;
31import android.net.Uri;
32import android.os.Bundle;
33import android.os.ParcelFileDescriptor;
34import android.provider.TimeZoneRulesDataContract;
35import android.provider.TimeZoneRulesDataContract.Operation;
36import android.support.annotation.NonNull;
37import android.support.annotation.Nullable;
38
39import java.io.File;
40import java.io.FileNotFoundException;
41import java.io.FileOutputStream;
42import java.io.IOException;
43import java.io.InputStream;
44import java.io.OutputStream;
45import java.util.Arrays;
46import java.util.Collections;
47import java.util.HashMap;
48import java.util.HashSet;
49import java.util.List;
50import java.util.Map;
51import java.util.Set;
52
53import static android.content.res.AssetManager.ACCESS_STREAMING;
54
55/**
56 * A basic implementation of a time zone data provider that can be used by OEMs to implement
57 * an APK asset-based solution for time zone updates.
58 */
59public final class TimeZoneRulesDataProvider extends ContentProvider {
60
61    static final String TAG = "TimeZoneRulesDataProvider";
62
63    private static final String METADATA_KEY_OPERATION = "android.timezoneprovider.OPERATION";
64
65    private static final Set<String> KNOWN_COLUMN_NAMES;
66    private static final Map<String, Class<?>> KNOWN_COLUMN_TYPES;
67
68    static {
69        Set<String> columnNames = new HashSet<>();
70        columnNames.add(Operation.COLUMN_TYPE);
71        columnNames.add(Operation.COLUMN_DISTRO_MAJOR_VERSION);
72        columnNames.add(Operation.COLUMN_DISTRO_MINOR_VERSION);
73        columnNames.add(Operation.COLUMN_RULES_VERSION);
74        columnNames.add(Operation.COLUMN_REVISION);
75        KNOWN_COLUMN_NAMES = Collections.unmodifiableSet(columnNames);
76
77        Map<String, Class<?>> columnTypes = new HashMap<>();
78        columnTypes.put(Operation.COLUMN_TYPE, String.class);
79        columnTypes.put(Operation.COLUMN_DISTRO_MAJOR_VERSION, Integer.class);
80        columnTypes.put(Operation.COLUMN_DISTRO_MINOR_VERSION, Integer.class);
81        columnTypes.put(Operation.COLUMN_RULES_VERSION, String.class);
82        columnTypes.put(Operation.COLUMN_REVISION, Integer.class);
83        KNOWN_COLUMN_TYPES = Collections.unmodifiableMap(columnTypes);
84    }
85
86    private final Map<String, Object> mColumnData = new HashMap<>();
87
88    @Override
89    public boolean onCreate() {
90        return true;
91    }
92
93    @Override
94    public void attachInfo(Context context, ProviderInfo info) {
95        super.attachInfo(context, info);
96
97        // Sanity check our security
98        if (!TimeZoneRulesDataContract.AUTHORITY.equals(info.authority)) {
99            // The authority looked for by the time zone updater is fixed.
100            throw new SecurityException(
101                    "android:authorities must be \"" + TimeZoneRulesDataContract.AUTHORITY + "\"");
102        }
103        if (!info.grantUriPermissions) {
104            throw new SecurityException("Provider must grant uri permissions");
105        }
106        if (!info.exported) {
107            // The content provider is accessed directly so must be exported.
108            throw new SecurityException("android:exported must be \"true\"");
109        }
110        if (info.pathPermissions != null || info.writePermission != null) {
111            // Use readPermission only to implement permissions.
112            throw new SecurityException("Use android:readPermission only");
113        }
114        if (!android.Manifest.permission.UPDATE_TIME_ZONE_RULES.equals(info.readPermission)) {
115            // Writing is not supported.
116            throw new SecurityException("android:readPermission must be set to \""
117                    + android.Manifest.permission.UPDATE_TIME_ZONE_RULES
118                    + "\" is: " + info.readPermission);
119        }
120
121        // info.metadata is not filled in by default. Must ask for it again.
122        final ProviderInfo infoWithMetadata = context.getPackageManager()
123                .resolveContentProvider(info.authority, PackageManager.GET_META_DATA);
124        Bundle metaData = infoWithMetadata.metaData;
125        if (metaData == null) {
126            throw new SecurityException("meta-data must be set");
127        }
128
129        // Work out what the operation type is.
130        String type;
131        try {
132            type = getMandatoryMetaDataString(metaData, METADATA_KEY_OPERATION);
133            mColumnData.put(Operation.COLUMN_TYPE, type);
134        } catch (IllegalArgumentException e) {
135            throw new SecurityException(METADATA_KEY_OPERATION + " meta-data not set.");
136        }
137
138        // Fill in version information if this is an install operation.
139        if (Operation.TYPE_INSTALL.equals(type)) {
140            // Extract the version information from the distro.
141            InputStream distroBytesInputStream;
142            try {
143                distroBytesInputStream = context.getAssets().open(TimeZoneDistro.FILE_NAME);
144            } catch (IOException e) {
145                throw new SecurityException(
146                        "Unable to open asset: " + TimeZoneDistro.FILE_NAME, e);
147            }
148            TimeZoneDistro distro = new TimeZoneDistro(distroBytesInputStream);
149            try {
150                DistroVersion distroVersion = distro.getDistroVersion();
151                mColumnData.put(Operation.COLUMN_DISTRO_MAJOR_VERSION,
152                        distroVersion.formatMajorVersion);
153                mColumnData.put(Operation.COLUMN_DISTRO_MINOR_VERSION,
154                        distroVersion.formatMinorVersion);
155                mColumnData.put(Operation.COLUMN_RULES_VERSION, distroVersion.rulesVersion);
156                mColumnData.put(Operation.COLUMN_REVISION, distroVersion.revision);
157            } catch (IOException | DistroException e) {
158                throw new SecurityException("Invalid asset: " + TimeZoneDistro.FILE_NAME, e);
159            }
160
161        }
162    }
163
164    @Override
165    public Cursor query(@NonNull Uri uri, @Nullable String[] projection, @Nullable String selection,
166            @Nullable String[] selectionArgs, @Nullable String sortOrder) {
167        if (!Operation.CONTENT_URI.equals(uri)) {
168            return null;
169        }
170        final List<String> projectionList = Arrays.asList(projection);
171        if (projection != null && !KNOWN_COLUMN_NAMES.containsAll(projectionList)) {
172            throw new UnsupportedOperationException(
173                    "Only " + KNOWN_COLUMN_NAMES + " columns supported.");
174        }
175
176        return new AbstractCursor() {
177            @Override
178            public int getCount() {
179                return 1;
180            }
181
182            @Override
183            public String[] getColumnNames() {
184                return projectionList.toArray(new String[0]);
185            }
186
187            @Override
188            public int getType(int column) {
189                String columnName = projectionList.get(column);
190                Class<?> columnJavaType = KNOWN_COLUMN_TYPES.get(columnName);
191                if (columnJavaType == String.class) {
192                    return Cursor.FIELD_TYPE_STRING;
193                } else if (columnJavaType == Integer.class) {
194                    return Cursor.FIELD_TYPE_INTEGER;
195                } else {
196                    throw new UnsupportedOperationException(
197                            "Unsupported type: " + columnJavaType + " for " + columnName);
198                }
199            }
200
201            @Override
202            public String getString(int column) {
203                checkPosition();
204                String columnName = projectionList.get(column);
205                if (KNOWN_COLUMN_TYPES.get(columnName) != String.class) {
206                    throw new UnsupportedOperationException();
207                }
208                return (String) mColumnData.get(columnName);
209            }
210
211            @Override
212            public short getShort(int column) {
213                checkPosition();
214                throw new UnsupportedOperationException();
215            }
216
217            @Override
218            public int getInt(int column) {
219                checkPosition();
220                String columnName = projectionList.get(column);
221                if (KNOWN_COLUMN_TYPES.get(columnName) != Integer.class) {
222                    throw new UnsupportedOperationException();
223                }
224                return (Integer) mColumnData.get(columnName);
225            }
226
227            @Override
228            public long getLong(int column) {
229                return getInt(column);
230            }
231
232            @Override
233            public float getFloat(int column) {
234                throw new UnsupportedOperationException();
235            }
236
237            @Override
238            public double getDouble(int column) {
239                checkPosition();
240                throw new UnsupportedOperationException();
241            }
242
243            @Override
244            public boolean isNull(int column) {
245                checkPosition();
246                return column != 0;
247            }
248        };
249    }
250
251    @Override
252    public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode)
253            throws FileNotFoundException {
254        if (!Operation.CONTENT_URI.equals(uri)) {
255            throw new FileNotFoundException("Unknown URI: " + uri);
256        }
257        if (!"r".equals(mode)) {
258            throw new FileNotFoundException("Only read-only access supported.");
259        }
260
261        // We cannot return the asset ParcelFileDescriptor from
262        // assets.openFd(name).getParcelFileDescriptor() here as the receiver in the reading
263        // process gets a ParcelFileDescriptor pointing at the whole .apk. Instead, we extract
264        // the asset file we want to storage then wrap that in a ParcelFileDescriptor.
265        File distroFile = null;
266        try {
267            distroFile = File.createTempFile("distro", null, getContext().getFilesDir());
268
269            AssetManager assets = getContext().getAssets();
270            try (InputStream is = assets.open(TimeZoneDistro.FILE_NAME, ACCESS_STREAMING);
271                 FileOutputStream fos = new FileOutputStream(distroFile, false /* append */)) {
272                copy(is, fos);
273            }
274
275            return ParcelFileDescriptor.open(distroFile, ParcelFileDescriptor.MODE_READ_ONLY);
276        } catch (IOException e) {
277            throw new RuntimeException("Unable to copy distro asset file", e);
278        } finally {
279            if (distroFile != null) {
280                // Even if we have an open file descriptor pointing at the file it should be safe to
281                // delete because of normal Unix file behavior. Deleting here avoids leaking any
282                // storage.
283                distroFile.delete();
284            }
285        }
286    }
287
288    @Override
289    public String getType(@NonNull Uri uri) {
290        return null;
291    }
292
293    @Override
294    public Uri insert(@NonNull Uri uri, @Nullable ContentValues values) {
295        throw new UnsupportedOperationException();
296    }
297
298    @Override
299    public int delete(@NonNull Uri uri, @Nullable String selection,
300            @Nullable String[] selectionArgs) {
301        throw new UnsupportedOperationException();
302    }
303
304    @Override
305    public int update(@NonNull Uri uri, @Nullable ContentValues values, @Nullable String selection,
306            @Nullable String[] selectionArgs) {
307        throw new UnsupportedOperationException();
308    }
309
310    private static String getMandatoryMetaDataString(Bundle metaData, String key) {
311        if (!metaData.containsKey(key)) {
312            throw new SecurityException("No metadata with key " + key + " found.");
313        }
314        return metaData.getString(key);
315    }
316
317    /**
318     * Copies all of the bytes from {@code in} to {@code out}. Neither stream is closed.
319     */
320    private static void copy(InputStream in, OutputStream out) throws IOException {
321        byte[] buffer = new byte[8192];
322        int c;
323        while ((c = in.read(buffer)) != -1) {
324            out.write(buffer, 0, c);
325        }
326    }
327}
328