1/**
2 * Copyright (C) 2016 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 android.support.v13.view.inputmethod;
18
19import android.support.annotation.RequiresApi;
20import android.content.ClipDescription;
21import android.net.Uri;
22import android.os.Build;
23import android.os.Bundle;
24import android.os.ResultReceiver;
25import android.support.annotation.NonNull;
26import android.support.annotation.Nullable;
27import android.text.TextUtils;
28import android.view.inputmethod.EditorInfo;
29import android.view.inputmethod.InputConnection;
30import android.view.inputmethod.InputConnectionWrapper;
31import android.view.inputmethod.InputContentInfo;
32
33/**
34 * Helper for accessing features in {@link InputConnection} introduced after API level 13 in a
35 * backwards compatible fashion.
36 */
37public final class InputConnectionCompat {
38
39    private interface InputConnectionCompatImpl {
40        boolean commitContent(@NonNull InputConnection inputConnection,
41                @NonNull InputContentInfoCompat inputContentInfo, int flags, @Nullable Bundle opts);
42
43        @NonNull
44        InputConnection createWrapper(@NonNull InputConnection ic,
45                @NonNull EditorInfo editorInfo, @NonNull OnCommitContentListener callback);
46    }
47
48    static final class InputContentInfoCompatBaseImpl implements InputConnectionCompatImpl {
49
50        private static String COMMIT_CONTENT_ACTION =
51                "android.support.v13.view.inputmethod.InputConnectionCompat.COMMIT_CONTENT";
52        private static String COMMIT_CONTENT_CONTENT_URI_KEY =
53                "android.support.v13.view.inputmethod.InputConnectionCompat.CONTENT_URI";
54        private static String COMMIT_CONTENT_DESCRIPTION_KEY =
55                "android.support.v13.view.inputmethod.InputConnectionCompat.CONTENT_DESCRIPTION";
56        private static String COMMIT_CONTENT_LINK_URI_KEY =
57                "android.support.v13.view.inputmethod.InputConnectionCompat.CONTENT_LINK_URI";
58        private static String COMMIT_CONTENT_OPTS_KEY =
59                "android.support.v13.view.inputmethod.InputConnectionCompat.CONTENT_OPTS";
60        private static String COMMIT_CONTENT_FLAGS_KEY =
61                "android.support.v13.view.inputmethod.InputConnectionCompat.CONTENT_FLAGS";
62        private static String COMMIT_CONTENT_RESULT_RECEIVER =
63                "android.support.v13.view.inputmethod.InputConnectionCompat.CONTENT_RESULT_RECEIVER";
64
65        @Override
66        public boolean commitContent(@NonNull InputConnection inputConnection,
67                @NonNull InputContentInfoCompat inputContentInfo, int flags,
68                @Nullable Bundle opts) {
69            final Bundle params = new Bundle();
70            params.putParcelable(COMMIT_CONTENT_CONTENT_URI_KEY, inputContentInfo.getContentUri());
71            params.putParcelable(COMMIT_CONTENT_DESCRIPTION_KEY, inputContentInfo.getDescription());
72            params.putParcelable(COMMIT_CONTENT_LINK_URI_KEY, inputContentInfo.getLinkUri());
73            params.putInt(COMMIT_CONTENT_FLAGS_KEY, flags);
74            params.putParcelable(COMMIT_CONTENT_OPTS_KEY, opts);
75            // TODO: Support COMMIT_CONTENT_RESULT_RECEIVER.
76            return inputConnection.performPrivateCommand(COMMIT_CONTENT_ACTION, params);
77        }
78
79        @NonNull
80        @Override
81        public InputConnection createWrapper(@NonNull InputConnection ic,
82                @NonNull EditorInfo editorInfo,
83                @NonNull OnCommitContentListener onCommitContentListener) {
84            String[] contentMimeTypes = EditorInfoCompat.getContentMimeTypes(editorInfo);
85            if (contentMimeTypes.length == 0) {
86                return ic;
87            }
88            final OnCommitContentListener listener = onCommitContentListener;
89            return new InputConnectionWrapper(ic, false /* mutable */) {
90                @Override
91                public boolean performPrivateCommand(String action, Bundle data) {
92                    if (InputContentInfoCompatBaseImpl.handlePerformPrivateCommand(action, data,
93                            listener)) {
94                        return true;
95                    }
96                    return super.performPrivateCommand(action, data);
97                }
98            };
99        }
100
101        static boolean handlePerformPrivateCommand(
102                @Nullable String action,
103                @NonNull Bundle data,
104                @NonNull OnCommitContentListener onCommitContentListener) {
105            if (!TextUtils.equals(COMMIT_CONTENT_ACTION, action)) {
106                return false;
107            }
108            if (data == null) {
109                return false;
110            }
111            ResultReceiver resultReceiver = null;
112            boolean result = false;
113            try {
114                resultReceiver = data.getParcelable(COMMIT_CONTENT_RESULT_RECEIVER);
115                final Uri contentUri = data.getParcelable(COMMIT_CONTENT_CONTENT_URI_KEY);
116                final ClipDescription description = data.getParcelable(
117                        COMMIT_CONTENT_DESCRIPTION_KEY);
118                final Uri linkUri = data.getParcelable(COMMIT_CONTENT_LINK_URI_KEY);
119                final int flags = data.getInt(COMMIT_CONTENT_FLAGS_KEY);
120                final Bundle opts = data.getParcelable(COMMIT_CONTENT_OPTS_KEY);
121                final InputContentInfoCompat inputContentInfo =
122                        new InputContentInfoCompat(contentUri, description, linkUri);
123                result = onCommitContentListener.onCommitContent(inputContentInfo, flags, opts);
124            } finally {
125                if (resultReceiver != null) {
126                    resultReceiver.send(result ? 1 : 0, null);
127                }
128            }
129            return result;
130        }
131    }
132
133    @RequiresApi(25)
134    private static final class InputContentInfoCompatApi25Impl
135            implements InputConnectionCompatImpl {
136        @Override
137        public boolean commitContent(@NonNull InputConnection inputConnection,
138                @NonNull InputContentInfoCompat inputContentInfo, int flags,
139                @Nullable Bundle opts) {
140            return inputConnection.commitContent((InputContentInfo) inputContentInfo.unwrap(),
141                    flags, opts);
142        }
143
144        @Nullable
145        @Override
146        public InputConnection createWrapper(
147                @Nullable InputConnection inputConnection, @NonNull EditorInfo editorInfo,
148                @Nullable OnCommitContentListener onCommitContentListener) {
149            final OnCommitContentListener listener = onCommitContentListener;
150            return new InputConnectionWrapper(inputConnection, false /* mutable */) {
151                @Override
152                public boolean commitContent(InputContentInfo inputContentInfo, int flags,
153                        Bundle opts) {
154                    if (listener.onCommitContent(InputContentInfoCompat.wrap(inputContentInfo),
155                            flags, opts)) {
156                        return true;
157                    }
158                    return super.commitContent(inputContentInfo, flags, opts);
159                }
160            };
161        }
162    }
163
164    private static final InputConnectionCompatImpl IMPL;
165    static {
166        if (Build.VERSION.SDK_INT >= 25) {
167            IMPL = new InputContentInfoCompatApi25Impl();
168        } else {
169            IMPL = new InputContentInfoCompatBaseImpl();
170        }
171    }
172
173    /**
174     * Calls commitContent API, in a backwards compatible fashion.
175     *
176     * @param inputConnection {@link InputConnection} with which commitContent API will be called
177     * @param editorInfo {@link EditorInfo} associated with the given {@code inputConnection}
178     * @param inputContentInfo content information to be passed to the editor
179     * @param flags {@code 0} or {@link #INPUT_CONTENT_GRANT_READ_URI_PERMISSION}
180     * @param opts optional bundle data. This can be {@code null}
181     * @return {@code true} if this request is accepted by the application, no matter if the request
182     * is already handled or still being handled in background
183     */
184    public static boolean commitContent(@NonNull InputConnection inputConnection,
185            @NonNull EditorInfo editorInfo, @NonNull InputContentInfoCompat inputContentInfo,
186            int flags, @Nullable Bundle opts) {
187        final ClipDescription description = inputContentInfo.getDescription();
188        boolean supported = false;
189        for (String mimeType : EditorInfoCompat.getContentMimeTypes(editorInfo)) {
190            if (description.hasMimeType(mimeType)) {
191                supported = true;
192                break;
193            }
194        }
195        if (!supported) {
196            return false;
197        }
198
199        return IMPL.commitContent(inputConnection, inputContentInfo, flags, opts);
200    }
201
202    /**
203     * When this flag is used, the editor will be able to request temporary access permissions to
204     * the content URI contained in the {@link InputContentInfoCompat} object, in a similar manner
205     * that has been recommended in
206     * <a href="{@docRoot}training/secure-file-sharing/index.html">Sharing Files</a>.
207     *
208     * <p>Make sure that the content provider owning the Uri sets the
209     * {@link android.R.attr#grantUriPermissions grantUriPermissions} attribute in its manifest or
210     * included the {@code &lt;grant-uri-permissions&gt;} tag.</p>
211     *
212     * <p>Supported only on API &gt;= 25.</p>
213     *
214     * <p>On API &lt;= 24 devices, IME developers need to ensure that the content URI is accessible
215     * only from the target application, for example, by generating a URL with a unique name that
216     * others cannot guess. IME developers can also rely on the following information of the target
217     * application to do additional access checks in their {@link android.content.ContentProvider}.
218     * </p>
219     * <ul>
220     *     <li>On API &gt;= 23 {@link EditorInfo#packageName} is guaranteed to not be spoofed, which
221     *     can later be compared with {@link android.content.ContentProvider#getCallingPackage()} in
222     *     the {@link android.content.ContentProvider}.
223     *     </li>
224     *     <li>{@link android.view.inputmethod.InputBinding#getUid()} is guaranteed to not be
225     *     spoofed, which can later be compared with {@link android.os.Binder#getCallingUid()} in
226     *     the {@link android.content.ContentProvider}.</li>
227     * </ul>
228     */
229    public static int INPUT_CONTENT_GRANT_READ_URI_PERMISSION = 0x00000001;
230
231    /**
232     * Listener for commitContent method call, in a backwards compatible fashion.
233     */
234    public interface OnCommitContentListener {
235        /**
236         * Intercepts InputConnection#commitContent API calls.
237         *
238         * @param inputContentInfo content to be committed
239         * @param flags {@code 0} or {@link #INPUT_CONTENT_GRANT_READ_URI_PERMISSION}
240         * @param opts optional bundle data. This can be {@code null}
241         * @return {@code true} if this request is accepted by the application, no matter if the
242         * request is already handled or still being handled in background. {@code false} to use the
243         * default implementation
244         */
245        boolean onCommitContent(InputContentInfoCompat inputContentInfo, int flags, Bundle opts);
246    }
247
248    /**
249     * Creates a wrapper {@link InputConnection} object from an existing {@link InputConnection}
250     * and {@link OnCommitContentListener} that can be returned to the system.
251     *
252     * <p>By returning the wrapper object to the IME, the editor can be notified by
253     * {@link OnCommitContentListener#onCommitContent(InputContentInfoCompat, int, Bundle)}
254     * when the IME calls
255     * {@link InputConnectionCompat#commitContent(InputConnection, EditorInfo,
256     * InputContentInfoCompat, int, Bundle)} and the corresponding Framework API that is available
257     * on API &gt;= 25.</p>
258     *
259     * @param inputConnection {@link InputConnection} to be wrapped
260     * @param editorInfo {@link EditorInfo} associated with the given {@code inputConnection}
261     * @param onCommitContentListener the listener that the wrapper object will call
262     * @return a wrapper {@link InputConnection} object that can be returned to the IME
263     * @throws IllegalArgumentException when {@code inputConnection}, {@code editorInfo}, or
264     * {@code onCommitContentListener} is {@code null}
265     */
266    @NonNull
267    public static InputConnection createWrapper(@NonNull InputConnection inputConnection,
268            @NonNull EditorInfo editorInfo,
269            @NonNull OnCommitContentListener onCommitContentListener) {
270        if (inputConnection == null) {
271            throw new IllegalArgumentException("inputConnection must be non-null");
272        }
273        if (editorInfo == null) {
274            throw new IllegalArgumentException("editorInfo must be non-null");
275        }
276        if (onCommitContentListener == null) {
277            throw new IllegalArgumentException("onCommitContentListener must be non-null");
278        }
279        return IMPL.createWrapper(inputConnection, editorInfo, onCommitContentListener);
280    }
281
282}
283