diff --git a/examples/android/clientcache/app/build.gradle b/examples/android/clientcache/app/build.gradle new file mode 100644 index 0000000000..5c0c647dcd --- /dev/null +++ b/examples/android/clientcache/app/build.gradle @@ -0,0 +1,67 @@ +apply plugin: 'com.android.application' +apply plugin: 'com.google.protobuf' + +android { + compileSdkVersion 25 + buildToolsVersion "25.0.2" + + defaultConfig { + applicationId "io.grpc.android.clientcacheexample" + minSdkVersion 19 + targetSdkVersion 25 + multiDexEnabled true + versionCode 1 + versionName "1.0" + testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" + } + buildTypes { + release { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + lintOptions { + disable 'InvalidPackage', 'HardcodedText' + textReport true + textOutput "stdout" + } +} + +protobuf { + protoc { + artifact = 'com.google.protobuf:protoc:3.4.0' + } + plugins { + javalite { + artifact = "com.google.protobuf:protoc-gen-javalite:3.0.0" + } + grpc { + artifact = 'io.grpc:protoc-gen-grpc-java:1.9.0-SNAPSHOT' // CURRENT_GRPC_VERSION + } + } + generateProtoTasks { + all().each { task -> + task.plugins { + javalite {} + grpc { + // Options added to --grpc_out + option 'lite' + } + } + } + } +} + +dependencies { + compile 'com.android.support:appcompat-v7:25.0.0' + + // You need to build grpc-java to obtain these libraries below. + compile 'io.grpc:grpc-okhttp:1.9.0-SNAPSHOT' // CURRENT_GRPC_VERSION + compile 'io.grpc:grpc-protobuf-lite:1.9.0-SNAPSHOT' // CURRENT_GRPC_VERSION + compile 'io.grpc:grpc-stub:1.9.0-SNAPSHOT' // CURRENT_GRPC_VERSION + compile 'javax.annotation:javax.annotation-api:1.2' + + testCompile 'junit:junit:4.12' + testCompile 'com.google.truth:truth:0.28' + testCompile 'io.grpc:grpc-testing:1.9.0-SNAPSHOT' +} diff --git a/examples/android/clientcache/app/proguard-rules.pro b/examples/android/clientcache/app/proguard-rules.pro new file mode 100644 index 0000000000..1507a52678 --- /dev/null +++ b/examples/android/clientcache/app/proguard-rules.pro @@ -0,0 +1,17 @@ +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in $ANDROID_HOME/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the proguardFiles +# directive in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Add any project specific keep options here: + +-dontwarn com.google.common.** +# Ignores: can't find referenced class javax.lang.model.element.Modifier +-dontwarn com.google.errorprone.annotations.** +-dontwarn javax.naming.** +-dontwarn okio.** +-dontwarn sun.misc.Unsafe diff --git a/examples/android/clientcache/app/src/main/AndroidManifest.xml b/examples/android/clientcache/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..d31cbd9409 --- /dev/null +++ b/examples/android/clientcache/app/src/main/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + diff --git a/examples/android/clientcache/app/src/main/java/io/grpc/clientcacheexample/ClientCacheExampleActivity.java b/examples/android/clientcache/app/src/main/java/io/grpc/clientcacheexample/ClientCacheExampleActivity.java new file mode 100644 index 0000000000..831c1030ac --- /dev/null +++ b/examples/android/clientcache/app/src/main/java/io/grpc/clientcacheexample/ClientCacheExampleActivity.java @@ -0,0 +1,152 @@ +/* + * Copyright 2015, gRPC Authors All rights reserved. + * + * 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 io.grpc.clientcacheexample; + +import android.content.Context; +import android.os.AsyncTask; +import android.os.Bundle; +import android.support.v7.app.AppCompatActivity; +import android.text.TextUtils; +import android.text.method.ScrollingMovementMethod; +import android.util.Log; +import android.view.View; +import android.view.inputmethod.InputMethodManager; +import android.widget.Button; +import android.widget.CheckBox; +import android.widget.EditText; +import android.widget.TextView; +import io.grpc.CallOptions; +import io.grpc.Channel; +import io.grpc.ClientInterceptors; +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; +import io.grpc.MethodDescriptor; +import io.grpc.examples.helloworld.GreeterGrpc; +import io.grpc.examples.helloworld.HelloReply; +import io.grpc.examples.helloworld.HelloRequest; +import io.grpc.stub.ClientCalls; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.concurrent.TimeUnit; + +public final class ClientCacheExampleActivity extends AppCompatActivity { + private static final int CACHE_SIZE_IN_BYTES = 1 * 1024 * 1024; // 1MB + private static final String TAG = "grpcCacheExample"; + private Button mSendButton; + private EditText mHostEdit; + private EditText mPortEdit; + private EditText mMessageEdit; + private TextView mResultText; + private CheckBox getCheckBox; + private CheckBox noCacheCheckBox; + private CheckBox onlyIfCachedCheckBox; + private SafeMethodCachingInterceptor.Cache cache; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_clientcacheexample); + mSendButton = (Button) findViewById(R.id.send_button); + mHostEdit = (EditText) findViewById(R.id.host_edit_text); + mPortEdit = (EditText) findViewById(R.id.port_edit_text); + mMessageEdit = (EditText) findViewById(R.id.message_edit_text); + getCheckBox = (CheckBox) findViewById(R.id.get_checkbox); + noCacheCheckBox = (CheckBox) findViewById(R.id.no_cache_checkbox); + onlyIfCachedCheckBox = (CheckBox) findViewById(R.id.only_if_cached_checkbox); + mResultText = (TextView) findViewById(R.id.grpc_response_text); + mResultText.setMovementMethod(new ScrollingMovementMethod()); + + cache = SafeMethodCachingInterceptor.newLruCache(CACHE_SIZE_IN_BYTES); + } + + /** Sends RPC. Invoked when app button is pressed. */ + public void sendMessage(View view) { + ((InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE)) + .hideSoftInputFromWindow(mHostEdit.getWindowToken(), 0); + mSendButton.setEnabled(false); + new GrpcTask().execute(); + } + + private class GrpcTask extends AsyncTask { + private String host; + private String message; + private int port; + private ManagedChannel channel; + + @Override + protected void onPreExecute() { + host = mHostEdit.getText().toString(); + message = mMessageEdit.getText().toString(); + String portStr = mPortEdit.getText().toString(); + port = TextUtils.isEmpty(portStr) ? 0 : Integer.valueOf(portStr); + mResultText.setText(""); + } + + @Override + protected String doInBackground(Void... nothing) { + try { + channel = ManagedChannelBuilder.forAddress(host, port).usePlaintext(true).build(); + Channel channelToUse = + ClientInterceptors.intercept( + channel, SafeMethodCachingInterceptor.newSafeMethodCachingInterceptor(cache)); + HelloRequest message = HelloRequest.newBuilder().setName(this.message).build(); + HelloReply reply; + if (getCheckBox.isChecked()) { + MethodDescriptor safeCacheableUnaryCallMethod = + GreeterGrpc.getSayHelloMethod().toBuilder().setSafe(true).build(); + CallOptions callOptions = CallOptions.DEFAULT; + if (noCacheCheckBox.isChecked()) { + callOptions = + callOptions.withOption(SafeMethodCachingInterceptor.NO_CACHE_CALL_OPTION, true); + } + if (onlyIfCachedCheckBox.isChecked()) { + callOptions = + callOptions.withOption( + SafeMethodCachingInterceptor.ONLY_IF_CACHED_CALL_OPTION, true); + } + reply = + ClientCalls.blockingUnaryCall( + channelToUse, safeCacheableUnaryCallMethod, callOptions, message); + } else { + GreeterGrpc.GreeterBlockingStub stub = GreeterGrpc.newBlockingStub(channelToUse); + reply = stub.sayHello(message); + } + return reply.getMessage(); + } catch (Exception e) { + Log.e(TAG, "RPC failed", e); + StringWriter sw = new StringWriter(); + PrintWriter pw = new PrintWriter(sw); + e.printStackTrace(pw); + pw.flush(); + return String.format("Failed... : %n%s", sw); + } + } + + @Override + protected void onPostExecute(String result) { + if (channel != null) { + try { + channel.shutdown().awaitTermination(1, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + mResultText.setText(result); + mSendButton.setEnabled(true); + } + } +} diff --git a/examples/android/clientcache/app/src/main/java/io/grpc/clientcacheexample/SafeMethodCachingInterceptor.java b/examples/android/clientcache/app/src/main/java/io/grpc/clientcacheexample/SafeMethodCachingInterceptor.java new file mode 100644 index 0000000000..4ae0c01c56 --- /dev/null +++ b/examples/android/clientcache/app/src/main/java/io/grpc/clientcacheexample/SafeMethodCachingInterceptor.java @@ -0,0 +1,300 @@ +package io.grpc.clientcacheexample; + +import android.util.Log; +import android.util.LruCache; +import com.google.common.base.Splitter; +import com.google.protobuf.MessageLite; +import io.grpc.CallOptions; +import io.grpc.Channel; +import io.grpc.ClientCall; +import io.grpc.ClientInterceptor; +import io.grpc.Deadline; +import io.grpc.ForwardingClientCall; +import io.grpc.ForwardingClientCallListener; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.Status; +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +/** + * An example of an on-device cache for Android implemented using the {@link ClientInterceptor} API. + * + *

Client-side cache-control directives are not directly supported. Instead, two call options can + * be added to the call: no-cache (always go to the network) or only-if-cached (never use network; + * if response is not in cache, the request fails). + * + *

This interceptor respects the cache-control directives in the server's response: max-age + * determines when the cache entry goes stale. no-cache, no-store, and no-transform entirely skip + * caching of the response. must-revalidate is ignored, as the cache does not support returning + * stale responses. + * + *

Note: other response headers besides cache-control (such as Expiration, Varies) are ignored by + * this implementation. + */ +final class SafeMethodCachingInterceptor implements ClientInterceptor { + static CallOptions.Key NO_CACHE_CALL_OPTION = CallOptions.Key.of("no-cache", false); + static CallOptions.Key ONLY_IF_CACHED_CALL_OPTION = + CallOptions.Key.of("only-if-cached", false); + private static final String TAG = "grpcCacheExample"; + + public static final class Key { + private final String fullMethodName; + private final MessageLite request; + + public Key(String fullMethodName, MessageLite request) { + this.fullMethodName = fullMethodName; + this.request = request; + } + + @Override + public boolean equals(Object object) { + if (object instanceof Key) { + Key other = (Key) object; + return Objects.equals(this.fullMethodName, other.fullMethodName) + && Objects.equals(this.request, other.request); + } + return false; + } + + @Override + public int hashCode() { + return Objects.hash(fullMethodName, request); + } + } + + public static final class Value { + private final MessageLite response; + private final Deadline maxAgeDeadline; + + public Value(MessageLite response, Deadline maxAgeDeadline) { + this.response = response; + this.maxAgeDeadline = maxAgeDeadline; + } + + @Override + public boolean equals(Object object) { + if (object instanceof Value) { + Value other = (Value) object; + return Objects.equals(this.response, other.response) + && Objects.equals(this.maxAgeDeadline, other.maxAgeDeadline); + } + return false; + } + + @Override + public int hashCode() { + return Objects.hash(response, maxAgeDeadline); + } + } + + public interface Cache { + void put(Key key, Value value); + + Value get(Key key); + + void remove(Key key); + + void clear(); + } + + /** + * Obtain a new cache with a least-recently used eviction policy and the specified size limit. The + * backing caching implementation is provided by {@link LruCache}. It is safe for a single cache + * to be shared across multiple {@link SafeMethodCachingInterceptor}s without synchronization. + */ + public static Cache newLruCache(final int cacheSizeInBytes) { + return new Cache() { + private final LruCache lruCache = + new LruCache(cacheSizeInBytes) { + protected int sizeOf(Key key, Value value) { + return value.response.getSerializedSize(); + } + }; + + @Override + public void put(Key key, Value value) { + lruCache.put(key, value); + } + + @Override + public Value get(Key key) { + return lruCache.get(key); + } + + @Override + public void remove(Key key) { + lruCache.remove(key); + } + + @Override + public void clear() { + lruCache.evictAll(); + } + }; + } + + public static SafeMethodCachingInterceptor newSafeMethodCachingInterceptor(Cache cache) { + return newSafeMethodCachingInterceptor(cache, DEFAULT_MAX_AGE_SECONDS); + } + + public static SafeMethodCachingInterceptor newSafeMethodCachingInterceptor( + Cache cache, int defaultMaxAge) { + return new SafeMethodCachingInterceptor(cache, defaultMaxAge); + } + + private static int DEFAULT_MAX_AGE_SECONDS = 3600; + + private static final Metadata.Key CACHE_CONTROL_KEY = + Metadata.Key.of("cache-control", Metadata.ASCII_STRING_MARSHALLER); + + private static final Splitter CACHE_CONTROL_SPLITTER = + Splitter.on(',').trimResults().omitEmptyStrings(); + + private final Cache internalCache; + private final int defaultMaxAge; + + private SafeMethodCachingInterceptor(Cache cache, int defaultMaxAge) { + this.internalCache = cache; + this.defaultMaxAge = defaultMaxAge; + } + + @Override + public ClientCall interceptCall( + final MethodDescriptor method, final CallOptions callOptions, Channel next) { + // Currently only unary methods can be marked safe, but check anyways. + if (!method.isSafe() || method.getType() != MethodDescriptor.MethodType.UNARY) { + return next.newCall(method, callOptions); + } + + final String fullMethodName = method.getFullMethodName(); + + return new ForwardingClientCall.SimpleForwardingClientCall( + next.newCall(method, callOptions)) { + private Listener interceptedListener; + private Key requestKey; + private boolean cacheResponse = true; + private volatile String cacheOptionsErrorMsg; + + @Override + public void start(Listener responseListener, Metadata headers) { + interceptedListener = + new ForwardingClientCallListener.SimpleForwardingClientCallListener( + responseListener) { + private Deadline deadline; + private int maxAge = -1; + + @Override + public void onHeaders(Metadata headers) { + Iterable cacheControlHeaders = headers.getAll(CACHE_CONTROL_KEY); + if (cacheResponse && cacheControlHeaders != null) { + for (String cacheControlHeader : cacheControlHeaders) { + for (String directive : CACHE_CONTROL_SPLITTER.split(cacheControlHeader)) { + if (directive.equalsIgnoreCase("no-cache")) { + cacheResponse = false; + break; + } else if (directive.equalsIgnoreCase("no-store")) { + cacheResponse = false; + break; + } else if (directive.equalsIgnoreCase("no-transform")) { + cacheResponse = false; + break; + } else if (directive.toLowerCase().startsWith("max-age")) { + String[] parts = directive.split("="); + if (parts.length == 2) { + try { + maxAge = Integer.parseInt(parts[1]); + } catch (NumberFormatException e) { + Log.e(TAG, "max-age directive failed to parse", e); + continue; + } + } + } + } + } + } + if (cacheResponse) { + if (maxAge > -1) { + deadline = Deadline.after(maxAge, TimeUnit.SECONDS); + } else { + deadline = Deadline.after(defaultMaxAge, TimeUnit.SECONDS); + } + } + super.onHeaders(headers); + } + + @Override + public void onMessage(RespT message) { + if (cacheResponse && !deadline.isExpired()) { + Value value = new Value((MessageLite) message, deadline); + internalCache.put(requestKey, value); + } + super.onMessage(message); + } + + @Override + public void onClose(Status status, Metadata trailers) { + if (cacheOptionsErrorMsg != null) { + // UNAVAILABLE is the canonical gRPC mapping for HTTP response code 504 (as used + // by the built-in Android HTTP request cache). + super.onClose( + Status.UNAVAILABLE.withDescription(cacheOptionsErrorMsg), new Metadata()); + } else { + super.onClose(status, trailers); + } + } + }; + delegate().start(interceptedListener, headers); + } + + @Override + public void sendMessage(ReqT message) { + boolean noCache = callOptions.getOption(NO_CACHE_CALL_OPTION); + boolean onlyIfCached = callOptions.getOption(ONLY_IF_CACHED_CALL_OPTION); + + if (noCache) { + if (onlyIfCached) { + cacheOptionsErrorMsg = "Unsatisfiable Request (no-cache and only-if-cached conflict)"; + super.cancel(cacheOptionsErrorMsg, null); + return; + } + cacheResponse = false; + super.sendMessage(message); + return; + } + + // Check the cache + requestKey = new Key(fullMethodName, (MessageLite) message); + Value cachedResponse = internalCache.get(requestKey); + if (cachedResponse != null) { + if (cachedResponse.maxAgeDeadline.isExpired()) { + internalCache.remove(requestKey); + } else { + cacheResponse = false; // already cached + interceptedListener.onMessage((RespT) cachedResponse.response); + Metadata metadata = new Metadata(); + interceptedListener.onClose(Status.OK, metadata); + return; + } + } + + if (onlyIfCached) { + cacheOptionsErrorMsg = + "Unsatisfiable Request (only-if-cached set, but value not in cache)"; + super.cancel(cacheOptionsErrorMsg, null); + return; + } + super.sendMessage(message); + } + + @Override + public void halfClose() { + if (cacheOptionsErrorMsg != null) { + // already canceled + return; + } + super.halfClose(); + } + }; + } +} diff --git a/examples/android/clientcache/app/src/main/proto/helloworld.proto b/examples/android/clientcache/app/src/main/proto/helloworld.proto new file mode 100644 index 0000000000..7469e1e47d --- /dev/null +++ b/examples/android/clientcache/app/src/main/proto/helloworld.proto @@ -0,0 +1,45 @@ +// Copyright 2015, gRPC Authors +// All rights reserved. +// +// 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. +syntax = "proto3"; + +option java_multiple_files = true; +option java_package = "io.grpc.examples.helloworld"; +option java_outer_classname = "HelloWorldProto"; +option objc_class_prefix = "HLW"; + +package helloworld; + +// The greeting service definition. +service Greeter { + // Sends a greeting + rpc SayHello (HelloRequest) returns (HelloReply) {} + + rpc SayAnotherHello (HelloRequest) returns (HelloReply) {} + +} + +service AnotherGreeter { + rpc SayHello (HelloRequest) returns (HelloReply) {} +} + +// The request message containing the user's name. +message HelloRequest { + string name = 1; +} + +// The response message containing the greetings +message HelloReply { + string message = 1; +} diff --git a/examples/android/clientcache/app/src/main/res/layout/activity_clientcacheexample.xml b/examples/android/clientcache/app/src/main/res/layout/activity_clientcacheexample.xml new file mode 100644 index 0000000000..2e48e8a273 --- /dev/null +++ b/examples/android/clientcache/app/src/main/res/layout/activity_clientcacheexample.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + +