/* * Copyright 2017 drakeet. https://github.com/drakeet * * 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 me.drakeet.floo; import android.app.Activity; import android.content.Context; import android.content.Intent; import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.os.Parcelable; import androidx.annotation.CheckResult; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import android.util.Log; import java.io.Serializable; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import org.json.JSONException; import static android.util.Log.DEBUG; import static android.util.Log.ERROR; import static android.util.Log.INFO; import static android.util.Log.WARN; import static java.lang.String.format; /** * @author drakeet */ public final class Floo implements Navigation { private static final String TAG = "Floo"; private static final Configuration CONFIGURATION = new Configuration(); private @NonNull final Context context; private @NonNull final Bundle bundle; private @NonNull final Map<String, String> queries; private @NonNull Uri sourceUri; private @Nullable Uri targetUri; private @Nullable Integer intentFlags; private Floo(@NonNull Context context, @NonNull String url) { this.context = context; this.bundle = new Bundle(); this.sourceUri = Uri.parse(url); this.queries = new HashMap<>(); } /** * Create a new {@link Navigation} to open a URL. * * @param context The context. * @param url The source URL. * @return A reference to the {@link Navigation}. */ @NonNull @CheckResult public static Navigation navigation(@NonNull Context context, @NonNull String url) { return new Floo(context, url.trim()); } /** * Create a new {@link Stack} to back. * * @param activity The current activity. * @return A reference to the {@link Stack}. */ @NonNull @CheckResult @RequiresApi(api = Build.VERSION_CODES.ICE_CREAM_SANDWICH) public static Stack stack(@NonNull Activity activity) { configuration().initStackObserverIfNeed(activity); return new Stack(activity); } /** * Initialize the {@code FlooDelegate}, will load the last rules map. * * @return The {@link Configuration}. */ @NonNull @CheckResult public static Configuration configuration() { return CONFIGURATION; } /** * Apply the rules map. It will replace the original map. * * @param map The rules map. */ public static void apply(@NonNull final Map<String, Target> map) { configuration().apply(map); } /** * Apply the rules map from a JSON. It will replace the original map. * * @param targetMapJson The target map JSON of your rules. * @throws JSONException When the JSON's format is error or unexpected. */ public static void apply(@NonNull final String targetMapJson) throws JSONException { configuration().apply(new TargetMapParser().fromJson(targetMapJson)); } /** * Set special flags controlling how this intent is handled. * * @param intentFlags The desired intent flags. * @return A reference to the {@link Navigation}. * @see Intent#setFlags(int) */ @NonNull @Override @CheckResult public Navigation setFlags(int intentFlags) { this.intentFlags = intentFlags; return this; } /** * Inserts all mappings from the given Bundle. * * @param bundle A Bundle. * @return A reference to the {@link Navigation}. * @see Navigation * @see #putExtra(String, byte) * @see #putExtra(String, short) * @see #putExtra(String, int) * @see #putExtra(String, long) * @see #putExtra(String, float) * @see #putExtra(String, double) * @see #putExtra(String, boolean) * @see #putExtra(String, String) * @see #putExtra(String, CharSequence) * @see #putExtra(String, Parcelable) * @see #putExtra(String, Serializable) */ @NonNull @Override @CheckResult public Navigation putExtras(@NonNull Bundle bundle) { bundle.putAll(bundle); return this; } /** * Append a query parameter. * * @return A reference to the {@link Navigation}. */ @NonNull @Override @CheckResult public Navigation appendQueryParameter(@NonNull String key, @NonNull String value) { queries.put(key, value); return this; } /** * Build and start the target URL. * * @see #getIntent() */ @Override public void start() { final Intent intent = getIntent(); if (intent != null) { configuration().getIntentHandler().onIntentCreated(context, intent); } else { log(WARN, "The target Intent is null, " + "it may has been intercepted or dispatched to your TargetNotFoundHandlers."); } } /** * A convenient method to get the non-null target intent. If the target intent is non-null, * it will be sent to the {@link IntentReceiver} so that you can do anything without checking * the intent. Otherwise, nothing happens. * * @param receiver The target intent receiver. */ @Override public void ifIntentNonNullSendTo(@NonNull IntentReceiver receiver) { Intent intent = getIntent(); if (intent != null) { receiver.onReceived(intent); } else { log(INFO, "ifIntentNonNullSendTo: intent == null"); } } /** * Get the result intent. Return null if the intent has been intercepted. * * @return The Intent, null if the intent has been intercepted. * @see #ifIntentNonNullSendTo(IntentReceiver) */ @Nullable @Override @CheckResult public Intent getIntent() { sourceUri = appendSourceUri(sourceUri, queries); Chain chain = interceptRequest(sourceUri); sourceUri = chain.request(); if (chain.isProceed()) { Target target = configuration().getTarget(getIndexUrl()); if (target != null) { targetUri = createTargetUri(sourceUri, target); } else { log(ERROR, getIndexUrl() + " target not found"); onTargetNotFound(sourceUri, bundle); chain = chain.abort(); } } if (chain.isProceed()) { assert targetUri != null; chain = interceptTarget(targetUri); targetUri = chain.request(); if (chain.isProceed()) { return createIntent(); } } return null; } @NonNull private Intent createIntent() { final Intent intent = new Intent(Intent.ACTION_VIEW, targetUri); if (configuration().isDebugEnabled()) { if (intent.getStringExtra("__source__") == null) { intent.putExtra("__source__", sourceUri.toString()); } if (intent.getStringExtra("__target__") == null) { assert targetUri != null; intent.putExtra("__target__", targetUri.toString()); } } intent.putExtras(bundle); if (intentFlags != null) { intent.setFlags(intentFlags); } return intent; } /** * Intercept the request URI and dispatch the {@link Chain} to all of registered {@link Interceptor}s * one by one. * * @param uri The request URI. * @return The result chain. * @see Configuration#addRequestInterceptor(Interceptor) */ @NonNull private Chain interceptRequest(@NonNull final Uri uri) { Chain chain = new Chain(uri); for (Interceptor interceptor : configuration().getRequestInterceptors()) { chain = interceptor.intercept(chain); if (!chain.isProceed()) { log(INFO, "The source URI has been passed to your " + interceptor.getClass().getName() + ", and been aborted by the interceptor."); break; } } return chain; } /** * Intercept the target URI and dispatch the {@link Chain} to all of registered {@link Interceptor}s * one by one. * * @param target The target URI. * @return The result chain. * @see Configuration#addTargetInterceptor(Interceptor) */ private Chain interceptTarget(@NonNull final Uri target) { Chain chain = new Chain(target); for (Interceptor interceptor : configuration().getTargetInterceptors()) { chain = interceptor.intercept(chain); if (!chain.isProceed()) { log(DEBUG, "The target URI has been passed to your " + interceptor.getClass().getName() + ", and been aborted by the interceptor."); break; } } return chain; } private void onTargetNotFound(@NonNull Uri sourceUri, @NonNull Bundle extras) { log(DEBUG, format("No target URI link to he source URI(%s)", sourceUri)); dispatchTargetNotFoundEvent(sourceUri, extras); } private void dispatchTargetNotFoundEvent(@NonNull Uri sourceUri, @NonNull Bundle extras) { boolean handled; for (TargetNotFoundHandler observer : configuration().getTargetNotFoundHandlers()) { handled = observer.onTargetNotFound(context, sourceUri, extras, intentFlags); if (handled) { log(DEBUG, "The TargetNotFoundEvent has been handled by " + observer.getClass().getName()); break; } } } @NonNull private Uri appendSourceUri(@NonNull final Uri base, @NonNull final Map<String, String> queryParams) { final Uri.Builder sourceBuilder = base.buildUpon(); for (Map.Entry<String, String> query : queryParams.entrySet()) { sourceBuilder.appendQueryParameter(query.getKey(), query.getValue()); } return sourceBuilder.build(); } @NonNull private Uri createTargetUri(@NonNull final Uri sourceUri, @NonNull final Target target) { final String targetUrl = target.toTargetUrl(); final Uri sessionUri = Uri.parse(targetUrl); final String mergedEncodedQuery = mergeEncodedQuery(sourceUri, sessionUri); return sourceUri.buildUpon() .scheme(sessionUri.getScheme()) .authority(sessionUri.getAuthority()) .path(sessionUri.getPath()) .encodedQuery(mergedEncodedQuery) .fragment(sessionUri.getFragment()) .build(); } @NonNull private String mergeEncodedQuery(@NonNull Uri sourceUri, @NonNull Uri sessionUri) { final Map<String, String> map = new HashMap<>(); map.putAll(encodedQueryParameters(sourceUri)); map.putAll(encodedQueryParameters(sessionUri)); StringBuilder builder = new StringBuilder(); for (Map.Entry<String, String> query : map.entrySet()) { builder.append(query.getKey()).append("=").append(query.getValue()).append("&"); } String result = builder.toString(); if (result.endsWith("&")) { result = result.substring(0, result.length() - 1); } return result; } @NonNull public static Map<String, Target> getTargetMap() { return Collections.unmodifiableMap(configuration().getTargetMap()); } /** * Get the unmodifiable target list. * * @return The target list. */ @NonNull public static List<Target> getTargets() { return Collections.unmodifiableList(new ArrayList<>(configuration().getTargetMap().values())); } /** * Check if exist the target for the navigation. * * @return If exist, true, otherwise false. */ @Override public boolean hasTarget() { return configuration().getTarget(getIndexUrl()) != null; } /** * Inserts an int value into the mapping of this Bundle, replacing * any existing value for the given key. * * @param key a String, or null * @param value an int */ @NonNull @Override @CheckResult public Navigation putExtra(@NonNull String key, int value) { bundle.putInt(key, value); return this; } @NonNull @Override @CheckResult public Navigation putExtra(@NonNull String key, long value) { bundle.putLong(key, value); return this; } @NonNull @Override @CheckResult public Navigation putExtra(@NonNull String key, float value) { bundle.putFloat(key, value); return this; } @NonNull @Override @CheckResult public Navigation putExtra(@NonNull String key, double value) { bundle.putDouble(key, value); return this; } @NonNull @Override @CheckResult public Navigation putExtra(@NonNull String key, boolean value) { bundle.putBoolean(key, value); return this; } @NonNull @Override @CheckResult public Navigation putExtra(@NonNull String key, byte value) { bundle.putByte(key, value); return this; } @NonNull @Override @CheckResult public Navigation putExtra(@NonNull String key, short value) { bundle.putShort(key, value); return this; } @NonNull @Override @CheckResult public Navigation putExtra(@NonNull String key, @Nullable String value) { bundle.putString(key, value); return this; } @NonNull @Override @CheckResult public Navigation putExtra(@NonNull String key, @Nullable CharSequence value) { bundle.putCharSequence(key, value); return this; } /** * Put {@link Parcelable} with key and value. * * @return A reference to the {@link Navigation}. * @see #putExtra(String, Serializable) */ @NonNull @Override @CheckResult public Navigation putExtra(@NonNull String key, @Nullable Parcelable value) { bundle.putParcelable(key, value); return this; } /** * Put {@link Serializable} with key and value. * * @return A reference to the {@link Navigation}. * @see #putExtra(String, Parcelable) */ @NonNull @Override @CheckResult public Navigation putExtra(@NonNull String key, @Nullable Serializable value) { bundle.putSerializable(key, value); return this; } @NonNull @Override @CheckResult public Navigation putExtras(@NonNull Intent intent) { Bundle extras = intent.getExtras(); if (extras != null) { return putExtras(extras); } return this; } @NonNull @Override @CheckResult public Navigation putIntegerArrayListExtra(@NonNull String name, @NonNull ArrayList<Integer> value) { bundle.putIntegerArrayList(name, value); return this; } @NonNull @Override @CheckResult public Navigation putStringArrayListExtra(@NonNull String name, @NonNull ArrayList<String> value) { bundle.putStringArrayList(name, value); return this; } @NonNull @Override @CheckResult public Navigation putCharSequenceArrayListExtra(@NonNull String name, @NonNull ArrayList<CharSequence> value) { bundle.putCharSequenceArrayList(name, value); return this; } @NonNull @Override @CheckResult public Navigation putParcelableArrayListExtra(@NonNull String name, @NonNull ArrayList<? extends Parcelable> value) { bundle.putParcelableArrayList(name, value); return this; } private static void log(int priority, String message) { if (configuration().isDebugEnabled()) { Log.println(priority, TAG, message); } } /** * Returns a map of the unique names of all query parameters. Iterating * over the set will return the names in order of their first occurrence. * * @param uri The URI to transform. * @return A map of decoded names. */ @NonNull private static Map<String, String> encodedQueryParameters(@NonNull final Uri uri) { final HashMap<String, String> map = new HashMap<>(); final String query = uri.getEncodedQuery(); if (query != null) { String[] ands = query.split("&"); for (String and : ands) { int splitIndex = and.indexOf("="); if (splitIndex != -1) { map.put(and.substring(0, splitIndex), and.substring(splitIndex + 1)); } else { Log.w("ParametersParseError", "query: " + query); } } } return map; } @NonNull private String getIndexUrl() { return Urls.indexUrl(sourceUri); } }