Commit 03f23709 authored by Yusuke Iwaki's avatar Yusuke Iwaki

GitHub authentication

parent eb93a930
......@@ -7,16 +7,21 @@ import android.support.annotation.Nullable;
import android.support.v4.app.Fragment;
import chat.rocket.android.LaunchUtil;
import chat.rocket.android.R;
import chat.rocket.android.fragment.login.GitHubOAuthWebViewFragment;
import chat.rocket.android.fragment.server_config.AuthenticatingFragment;
import chat.rocket.android.fragment.server_config.ConnectingToHostFragment;
import chat.rocket.android.fragment.server_config.InputHostnameFragment;
import chat.rocket.android.fragment.server_config.LoginFragment;
import chat.rocket.android.helper.LogcatIfError;
import chat.rocket.android.helper.TextUtils;
import chat.rocket.android.model.ServerConfig;
import chat.rocket.android.model.ServerConfigCredential;
import chat.rocket.android.service.RocketChatService;
import io.realm.Realm;
import io.realm.RealmQuery;
import java.util.List;
import jp.co.crowdworks.realm_java_helpers.RealmObjectObserver;
import jp.co.crowdworks.realm_java_helpers_bolts.RealmHelperBolts;
/**
* Activity for Login, Sign-up, and Connecting...
......@@ -54,7 +59,8 @@ public class ServerConfigActivity extends AbstractFragmentActivity {
}
for (ServerConfig config : configList) {
if (TextUtils.isEmpty(config.getSelectedProviderName())) {
ServerConfigCredential credential = config.getCredential();
if (credential != null && !TextUtils.isEmpty(credential.getType())) {
return launchFor(context, config);
}
}
......@@ -128,9 +134,13 @@ public class ServerConfigActivity extends AbstractFragmentActivity {
return;
}
final String selectedProviderName = config.getSelectedProviderName();
if (!TextUtils.isEmpty(selectedProviderName)) {
final ServerConfigCredential credential = config.getCredential();
if (credential != null && !TextUtils.isEmpty(credential.getType())) {
if (ServerConfigCredential.hasSecret(credential)) {
showFragment(new AuthenticatingFragment());
} else {
showFragment(getAuthFragmentFor(credential.getType()));
}
return;
}
......@@ -149,6 +159,21 @@ public class ServerConfigActivity extends AbstractFragmentActivity {
showFragment(new InputHostnameFragment());
}
private Fragment getAuthFragmentFor(final String authType) {
if ("github".equals(authType)) {
return GitHubOAuthWebViewFragment.create(serverConfigId);
} else if ("twitter".equals(authType)) {
// TODO
}
RealmHelperBolts.executeTransaction(realm -> realm.where(ServerConfigCredential.class)
.equalTo("type", authType)
.findAll()
.deleteAllFromRealm()
).continueWith(new LogcatIfError());
throw new IllegalArgumentException("Invalid authType given:" + authType);
}
@Override protected void showFragment(Fragment fragment) {
injectServerConfigIdArgTo(fragment);
super.showFragment(fragment);
......
package chat.rocket.android.fragment;
import android.graphics.Bitmap;
import android.os.Build;
import android.os.Message;
import android.view.View;
import android.webkit.WebResourceError;
import android.webkit.WebResourceRequest;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import chat.rocket.android.R;
import chat.rocket.android.helper.OnBackPressListener;
import hugo.weaving.DebugLog;
public abstract class AbstractWebViewFragment extends AbstractFragment implements OnBackPressListener {
private WebView webview;
private WebViewClient mWebViewClient = new WebViewClient() {
private boolean mError;
@Override public void onPageStarted(WebView webview, String url, Bitmap favicon) {
mError = false;
}
@Override public void onPageFinished(WebView webview, String url) {
if (!mError) onPageLoaded(webview, url);
}
@Override
public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) {
super.onReceivedError(view, request, error);
mError = true;
}
@Override public boolean shouldOverrideUrlLoading(WebView webview, String url) {
return (shouldOverride(webview, url) && onHandleCallback(webview, url))
|| super.shouldOverrideUrlLoading(webview, url);
}
@DebugLog @Override
public void onFormResubmission(WebView view, Message dontResend, Message resend) {
//resend POST request without confirmation.
resend.sendToTarget();
}
};
@Override protected int getLayout() {
return R.layout.webview;
}
@Override protected void onSetupView() {
webview = (WebView) rootView.findViewById(R.id.webview);
setupWebView();
navigateToInitialPage(webview);
}
private void setupWebView() {
WebSettings settings = webview.getSettings();
if (settings != null) {
settings.setJavaScriptEnabled(true);
}
webview.setHorizontalScrollBarEnabled(false);
webview.setWebViewClient(mWebViewClient);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
WebView.setWebContentsDebuggingEnabled(true);
}
if (Build.VERSION.SDK_INT == Build.VERSION_CODES.JELLY_BEAN) {
//refs: https://code.google.com/p/android/issues/detail?id=35288
webview.setLayerType(View.LAYER_TYPE_SOFTWARE, null);
}
}
@Override public boolean onBackPressed() {
if (webview.canGoBack()) {
webview.goBack();
return true;
} else {
return false;
}
}
protected abstract void navigateToInitialPage(WebView webview);
protected void onPageLoaded(WebView webview, String url) {
}
protected boolean shouldOverride(WebView webview, String url) {
return false;
}
protected boolean onHandleCallback(WebView webview, String url) {
return false;
}
;
}
package chat.rocket.android.fragment.login;
import android.os.Bundle;
import android.support.annotation.Nullable;
import android.support.v4.app.Fragment;
import android.text.TextUtils;
import android.util.Base64;
import android.webkit.JavascriptInterface;
import android.webkit.WebView;
import chat.rocket.android.fragment.AbstractWebViewFragment;
import chat.rocket.android.helper.LogcatIfError;
import chat.rocket.android.model.MeteorLoginServiceConfiguration;
import chat.rocket.android.model.ServerConfig;
import jp.co.crowdworks.realm_java_helpers.RealmHelper;
import jp.co.crowdworks.realm_java_helpers_bolts.RealmHelperBolts;
import okhttp3.HttpUrl;
import org.json.JSONException;
import org.json.JSONObject;
import timber.log.Timber;
public class GitHubOAuthWebViewFragment extends AbstractWebViewFragment {
private String serverConfigId;
private String hostname;
private String url;
private boolean resultOK;
public static Fragment create(final String serverConfigId) {
Bundle args = new Bundle();
args.putString("server_config_id", serverConfigId);
Fragment fragment = new GitHubOAuthWebViewFragment();
fragment.setArguments(args);
return fragment;
}
private boolean hasValidArgs(Bundle args) {
return args != null
&& args.containsKey("server_config_id");
}
@Override public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Bundle args = getArguments();
if (!hasValidArgs(args)) {
throw new IllegalArgumentException(
"server_config_id required");
}
serverConfigId = args.getString("server_config_id");
ServerConfig serverConfig = RealmHelper.executeTransactionForRead(realm ->
realm.where(ServerConfig.class).equalTo("id", serverConfigId).findFirst());
MeteorLoginServiceConfiguration oauthConfig = RealmHelper.executeTransactionForRead(realm ->
realm.where(MeteorLoginServiceConfiguration.class)
.equalTo("service", "github")
.equalTo("serverConfig.id", serverConfigId)
.findFirst());
if (serverConfig == null || oauthConfig == null) {
throw new IllegalArgumentException(
"Invalid server_config_id given,");
}
hostname = serverConfig.getHostname();
url = generateURL(oauthConfig.getClientId());
}
private String generateURL(String clientId) {
try {
String state = Base64.encodeToString(new JSONObject().put("loginStyle", "popup")
.put("credentialToken", "github" + System.currentTimeMillis())
.put("isCordova", true)
.toString()
.getBytes(), Base64.NO_WRAP);
return new HttpUrl.Builder().scheme("https")
.host("github.com")
.addPathSegment("login")
.addPathSegment("oauth")
.addPathSegment("authorize")
.addQueryParameter("client_id", clientId)
.addQueryParameter("scope", "user:email")
.addQueryParameter("state", state)
.build()
.toString();
} catch (Exception exception) {
Timber.e(exception, "failed to generate GitHub OAUth URL");
}
return null;
}
@Override protected void navigateToInitialPage(WebView webview) {
if (TextUtils.isEmpty(url)) {
finish();
return;
}
resultOK = false;
webview.loadUrl(url);
webview.addJavascriptInterface(new JSInterface(result -> {
// onPageFinish is called twice... Should ignore latter one.
if (resultOK) {
return;
}
if (result != null && result.optBoolean("setCredentialToken", false)) {
try {
final String credentialToken = result.getString("credentialToken");
final String credentialSecret = result.getString("credentialSecret");
handleOAuthCallback(credentialToken, credentialSecret);
resultOK = true;
} catch (JSONException exception) {
Timber.e(exception, "failed to parse OAuth result.");
}
}
onOAuthCompleted();
}), "_rocketchet_hook");
}
@Override protected void onPageLoaded(WebView webview, String url) {
super.onPageLoaded(webview, url);
if (url.contains(hostname) && url.contains("_oauth/github?close")) {
webview.loadUrl(
"javascript:window._rocketchet_hook.handleConfig(document.getElementById('config').innerText);");
}
}
private interface JSInterfaceCallback {
void hanldeResult(@Nullable JSONObject result);
}
private static final class JSInterface {
private final JSInterfaceCallback mCallback;
public JSInterface(JSInterfaceCallback callback) {
mCallback = callback;
}
@JavascriptInterface public void handleConfig(String config) {
try {
mCallback.hanldeResult(new JSONObject(config));
} catch (Exception e) {
mCallback.hanldeResult(null);
}
}
}
private void handleOAuthCallback(final String credentialToken, final String credentialSecret) {
RealmHelperBolts.executeTransaction(realm ->
realm.createOrUpdateObjectFromJson(ServerConfig.class, new JSONObject()
.put("id", serverConfigId)
.put("credential", new JSONObject()
.put("type", "github")
.put("credentialToken", credentialToken)
.put("credentialSecret", credentialSecret))
)
).continueWith(new LogcatIfError());
}
private void onOAuthCompleted() {
}
}
package chat.rocket.android.fragment.server_config;
import android.view.View;
import android.widget.TextView;
import chat.rocket.android.R;
/**
*/
public class AuthenticatingFragment extends AbstractServerConfigFragment {
@Override protected int getLayout() {
return R.layout.fragment_wait_for_connection;
}
@Override protected void onSetupView() {
TextView caption = (TextView) rootView.findViewById(R.id.txt_caption);
caption.setVisibility(View.VISIBLE);
caption.setText("Authenticationg...");
}
}
\ No newline at end of file
package chat.rocket.android.fragment.server_config;
import android.view.View;
import android.widget.TextView;
import chat.rocket.android.R;
/**
......@@ -11,6 +13,7 @@ public class ConnectingToHostFragment extends AbstractServerConfigFragment {
}
@Override protected void onSetupView() {
TextView caption = (TextView) rootView.findViewById(R.id.txt_caption);
caption.setVisibility(View.GONE);
}
}
package chat.rocket.android.fragment.server_config;
import android.view.View;
import android.widget.TextView;
import chat.rocket.android.R;
import chat.rocket.android.helper.LogcatIfError;
import chat.rocket.android.helper.TextUtils;
import chat.rocket.android.model.MeteorLoginServiceConfiguration;
import chat.rocket.android.model.ServerConfig;
import io.realm.Realm;
import io.realm.RealmResults;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.List;
import jp.co.crowdworks.realm_java_helpers.RealmListObserver;
import jp.co.crowdworks.realm_java_helpers_bolts.RealmHelperBolts;
import org.json.JSONObject;
/**
* Login screen.
......@@ -30,7 +38,39 @@ public class LoginFragment extends AbstractServerConfigFragment {
};
@Override protected void onSetupView() {
final View btnEmail = rootView.findViewById(R.id.btn_login_with_email);
final TextView txtUsername = (TextView) rootView.findViewById(R.id.editor_username);
final TextView txtPasswd = (TextView) rootView.findViewById(R.id.editor_passwd);
btnEmail.setOnClickListener(view -> {
final CharSequence username = txtUsername.getText();
final CharSequence passwd = txtPasswd.getText();
if (TextUtils.isEmpty(username) || TextUtils.isEmpty(passwd)) return;
RealmHelperBolts.executeTransaction(realm ->
realm.createOrUpdateObjectFromJson(ServerConfig.class, new JSONObject()
.put("id", serverConfigId)
.put("credential", new JSONObject()
.put("type", "email")
.put("username", username.toString())
.put("hashedPasswd", sha256sum(passwd.toString())))
)
).continueWith(new LogcatIfError());
});
}
private static String sha256sum(String orig) {
MessageDigest d = null;
try {
d = MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException e) {
return null;
}
d.update(orig.getBytes());
StringBuilder sb = new StringBuilder();
for(byte b : d.digest()) sb.append(String.format("%02x", b & 0xff));
return sb.toString();
}
private void onRenderAuthProviders(List<MeteorLoginServiceConfiguration> authProviders) {
......@@ -42,13 +82,38 @@ public class LoginFragment extends AbstractServerConfigFragment {
for (MeteorLoginServiceConfiguration authProvider : authProviders) {
if (!hasTwitter && "twitter".equals(authProvider.getService())) {
hasTwitter = true;
btnTwitter.setOnClickListener(view -> {
setAuthType("twitter");
});
}
if (!hasGitHub && "github".equals(authProvider.getService())) {
hasGitHub = true;
btnGitHub.setOnClickListener(view -> {
setAuthType("github");
});
}
}
btnTwitter.setVisibility(hasTwitter ? View.VISIBLE : View.GONE);
btnGitHub.setVisibility(hasGitHub ? View.VISIBLE : View.GONE);
}
private void setAuthType(final String authType) {
RealmHelperBolts.executeTransaction(realm ->
realm.createOrUpdateObjectFromJson(ServerConfig.class, new JSONObject()
.put("id", serverConfigId)
.put("credential", new JSONObject()
.put("type", authType)))
).continueWith(new LogcatIfError());
}
@Override public void onResume() {
super.onResume();
authProvidersObserver.sub();
}
@Override public void onPause() {
authProvidersObserver.unsub();
super.onPause();
}
}
......@@ -21,7 +21,7 @@ public class ServerConfig extends RealmObject {
private String session;
private String token;
private boolean tokenVerified;
private String selectedProviderName;
private ServerConfigCredential credential;
public static RealmQuery<ServerConfig> queryLoginRequiredConnections(Realm realm) {
return realm.where(ServerConfig.class).equalTo("tokenVerified", false);
......@@ -95,11 +95,11 @@ public class ServerConfig extends RealmObject {
this.tokenVerified = tokenVerified;
}
public String getSelectedProviderName() {
return selectedProviderName;
public ServerConfigCredential getCredential() {
return credential;
}
public void setSelectedProviderName(String selectedProviderName) {
this.selectedProviderName = selectedProviderName;
public void setCredential(ServerConfigCredential credential) {
this.credential = credential;
}
}
package chat.rocket.android.model;
import chat.rocket.android.helper.TextUtils;
import io.realm.RealmObject;
import io.realm.annotations.PrimaryKey;
import jp.co.crowdworks.realm_java_helpers_bolts.RealmHelperBolts;
import org.json.JSONObject;
public class ServerConfigCredential extends RealmObject {
@PrimaryKey private String type;
private String credentialToken;
private String credentialSecret;
private String username;
private String hashedPasswd;
public String getType() {
return type;
}
public void setType(String type) {
this.type = type;
}
public String getCredentialToken() {
return credentialToken;
}
public void setCredentialToken(String credentialToken) {
this.credentialToken = credentialToken;
}
public String getCredentialSecret() {
return credentialSecret;
}
public void setCredentialSecret(String credentialSecret) {
this.credentialSecret = credentialSecret;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getHashedPasswd() {
return hashedPasswd;
}
public void setHashedPasswd(String hashedPasswd) {
this.hashedPasswd = hashedPasswd;
}
public static boolean hasSecret(ServerConfigCredential credential) {
if (credential == null) {
return false;
}
final String authType = credential.getType();
if (TextUtils.isEmpty(authType)) {
return false;
}
if ("github".equals(authType) || "twitter".equals(authType)) {
return !TextUtils.isEmpty(credential.getCredentialToken())
&& !TextUtils.isEmpty(credential.getCredentialSecret());
} else if ("email".equals(authType)) {
return !TextUtils.isEmpty(credential.getUsername())
&& !TextUtils.isEmpty(credential.getHashedPasswd());
}
return false;
}
}
......@@ -10,6 +10,7 @@ import chat.rocket.android.helper.LogcatIfError;
import chat.rocket.android.helper.TextUtils;
import chat.rocket.android.model.ServerConfig;
import chat.rocket.android.service.ddp_subscriber.LoginServiceConfigurationSubscriber;
import chat.rocket.android.service.observer.LoginCredentialObserver;
import chat.rocket.android.ws.RocketChatWebSocketAPI;
import chat.rocket.android_ddp.DDPClientCallback;
import hugo.weaving.DebugLog;
......@@ -26,7 +27,8 @@ import timber.log.Timber;
*/
public class RocketChatWebSocketThread extends HandlerThread {
private static final Class[] REGISTERABLE_CLASSES = {
LoginServiceConfigurationSubscriber.class
LoginServiceConfigurationSubscriber.class,
LoginCredentialObserver.class
};
private final Context appContext;
private final String serverConfigId;
......@@ -128,7 +130,7 @@ public class RocketChatWebSocketThread extends HandlerThread {
socketExists = true;
final ServerConfig config = RealmHelper.executeTransactionForRead(
realm -> realm.where(ServerConfig.class).equalTo("id", serverConfigId).findFirst());
realm -> realm.where(ServerConfig.class).equalTo("id", serverConfigId).findFirst());
prepareWebSocket(config);
return webSocketAPI.connect(config.getSession()).onSuccessTask(task -> {
......@@ -163,6 +165,14 @@ public class RocketChatWebSocketThread extends HandlerThread {
//@DebugLog
private void registerListenersActually() {
if (!Thread.currentThread().getName().equals("RC_thread_" + serverConfigId)) {
// execute in Looper.
new Handler(getLooper()).post(() -> {
registerListenersActually();
});
return;
}
if (listenersRegistered) {
return;
}
......
package chat.rocket.android.service.observer;
import android.content.Context;
import bolts.Task;
import chat.rocket.android.helper.LogcatIfError;
import chat.rocket.android.helper.TextUtils;
import chat.rocket.android.model.ServerConfig;
import chat.rocket.android.ws.RocketChatWebSocketAPI;
import chat.rocket.android_ddp.DDPClientCallback;
import io.realm.Realm;
import io.realm.RealmResults;
import java.util.List;
import jp.co.crowdworks.realm_java_helpers_bolts.RealmHelperBolts;
import org.json.JSONObject;
public class LoginCredentialObserver extends AbstractModelObserver<ServerConfig> {
public LoginCredentialObserver(Context context, String serverConfigId,
RocketChatWebSocketAPI api) {
super(context, serverConfigId, api);
}
@Override protected RealmResults<ServerConfig> queryItems(Realm realm) {
return realm.where(ServerConfig.class)
.equalTo("tokenVerified", false)
.beginGroup()
.equalTo("credential.type", "email")
.isNotNull("credential.username")
.isNotNull("credential.hashedPasswd")
.or()
.notEqualTo("credential.type", "email")
.isNotNull("credential.credentialToken")
.isNotNull("credential.credentialSecret")
.endGroup()
.findAll();
}
@Override protected void onCollectionChanged(List<ServerConfig> list) {
if (list.isEmpty()) {
return;
}
ServerConfig config = list.get(0);
final String serverConfigId = config.getId();
login(config).onSuccessTask(task -> {
final String token = task.getResult().result.getString("token");
return RealmHelperBolts.executeTransaction(realm ->
realm.createOrUpdateObjectFromJson(ServerConfig.class, new JSONObject()
.put("id", serverConfigId)
.put("token", token)
.put("tokenVerified", true)));
}).continueWith(task -> {
if (task.isFaulted()) {
RealmHelperBolts.executeTransaction(realm -> {
ServerConfig _config = realm.where(ServerConfig.class)
.equalTo("id", serverConfigId)
.findFirst();
if (_config != null) {
_config.getCredential().deleteFromRealm();
_config.setToken(null);
}
return null;
}).continueWith(new LogcatIfError());
}
return null;
});
}
private Task<DDPClientCallback.RPC> login(ServerConfig config) {
if (!TextUtils.isEmpty(config.getToken())) {
return webSocketAPI.loginWithToken(config.getToken());
}
return webSocketAPI.login(config.getCredential());
}
}
package chat.rocket.android.ws;
import android.support.annotation.Nullable;
import android.util.Patterns;
import bolts.Task;
import chat.rocket.android.helper.OkHttpHelper;
import chat.rocket.android.model.ServerConfigCredential;
import chat.rocket.android_ddp.DDPClient;
import chat.rocket.android_ddp.DDPClientCallback;
import chat.rocket.android_ddp.DDPSubscription;
import java.util.UUID;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import rx.Observable;
/**
......@@ -70,4 +74,55 @@ public class RocketChatWebSocketAPI {
public Observable<DDPSubscription.Event> getSubscriptionCallback() {
return ddpClient.getSubscriptionCallback();
}
private String generateId(String method) {
return method + "-" + UUID.randomUUID().toString().replace("-", "");
}
/**
* Login with ServerConfigCredential.
*/
public Task<DDPClientCallback.RPC> login(ServerConfigCredential credential) {
JSONObject param = new JSONObject();
try {
String authType = credential.getType();
if ("email".equals(authType)) {
String username = credential.getUsername();
if (Patterns.EMAIL_ADDRESS.matcher(username).matches()) {
param.put("user", new JSONObject().put("email", username));
} else {
param.put("user", new JSONObject().put("username", username));
}
param.put("password", new JSONObject()
.put("digest", credential.getHashedPasswd())
.put("algorithm", "sha-256"));
} else if ("github".equals(authType) || "twitter".equals(authType)) {
param.put("oauth", new JSONObject()
.put("credentialToken", credential.getCredentialToken())
.put("credentialSecret", credential.getCredentialSecret()));
}
}
catch (JSONException e) {
return Task.forError(e);
}
return ddpClient.rpc("login", new JSONArray().put(param), generateId("login"));
}
public Task<DDPClientCallback.RPC> loginWithToken(final String token) {
JSONObject param = new JSONObject();
try {
param.put("resume", token);
} catch (JSONException e) {
return Task.forError(e);
}
return ddpClient.rpc("login", new JSONArray().put(param), generateId("login-token"));
}
public Task<DDPClientCallback.RPC> logout() {
return ddpClient.rpc("logout", null, generateId("logout"));
}
}
......@@ -35,6 +35,7 @@
android:text="@string/fa_twitter"
android:textSize="16dp"
android:layout_marginEnd="@dimen/margin_8"
android:enabled="false"
/>
<chat.rocket.android.view.FontAwesomeButton
android:id="@+id/btn_login_with_github"
......@@ -94,7 +95,7 @@
/>
<android.support.design.widget.FloatingActionButton
android:id="@+id/btn_login"
android:id="@+id/btn_login_with_email"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end|bottom"
......
......@@ -3,11 +3,27 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorPrimaryDark"
android:theme="@style/AppTheme.Dark"
>
<chat.rocket.android.view.WaitingView
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_gravity="center"
/>
android:gravity="center"
>
<chat.rocket.android.view.WaitingView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
/>
<TextView
android:id="@+id/txt_caption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.AppCompat.Caption"
android:layout_marginTop="@dimen/margin_32"
android:text="Connecting..."/>
</LinearLayout>
</FrameLayout>
\ No newline at end of file
<?xml version="1.0" encoding="utf-8"?>
<WebView
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/webview"
android:layout_width="match_parent"
android:layout_height="match_parent"
/>
\ No newline at end of file
......@@ -3,4 +3,5 @@
<dimen name="margin_8">8dp</dimen>
<dimen name="margin_16">16dp</dimen>
<dimen name="margin_24">24dp</dimen>
<dimen name="margin_32">32dp</dimen>
</resources>
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment