diff --git a/README.md b/README.md index 17c374c0..7b3c1db0 100644 --- a/README.md +++ b/README.md @@ -92,5 +92,6 @@ Open source libraries - Droidsonroids.gif/android-gif-drawable - Barteksc/AndroidPdfViewer - Mikepenz/fastadapter +- Ge0rg/MemorizingTrustManager [Follow me on Fediverse - mastodon.social/@mmarif](https://mastodon.social/@mmarif) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d029de5d..36a1c064 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -74,6 +74,7 @@ + \ No newline at end of file diff --git a/app/src/main/java/org/mian/gitnex/activities/IssueDetailActivity.java b/app/src/main/java/org/mian/gitnex/activities/IssueDetailActivity.java index 5ed1f303..2bada8f0 100644 --- a/app/src/main/java/org/mian/gitnex/activities/IssueDetailActivity.java +++ b/app/src/main/java/org/mian/gitnex/activities/IssueDetailActivity.java @@ -50,10 +50,10 @@ import android.widget.RelativeLayout; import android.widget.ScrollView; import android.widget.TextView; import com.amulyakhare.textdrawable.TextDrawable; -import com.squareup.picasso.Picasso; import com.vdurmont.emoji.EmojiParser; import org.mian.gitnex.R; import org.mian.gitnex.adapters.IssueCommentsAdapter; +import org.mian.gitnex.clients.PicassoService; import org.mian.gitnex.clients.RetrofitClient; import org.mian.gitnex.fragments.BottomSheetSingleIssueFragment; import org.mian.gitnex.helpers.AlertDialogs; @@ -388,7 +388,7 @@ public class IssueDetailActivity extends BaseActivity { tinyDb.putString("issueState", singleIssue.getState()); tinyDb.putString("issueTitle", singleIssue.getTitle()); - Picasso.get().load(singleIssue.getUser().getAvatar_url()).placeholder(R.drawable.loader_animated).transform(new RoundedTransformation(8, 0)).resize(120, 120).centerCrop().into(assigneeAvatar); + PicassoService.getInstance(ctx).get().load(singleIssue.getUser().getAvatar_url()).placeholder(R.drawable.loader_animated).transform(new RoundedTransformation(8, 0)).resize(120, 120).centerCrop().into(assigneeAvatar); String issueNumber_ = "" + getApplicationContext().getResources().getString(R.string.hash) + singleIssue.getNumber() + ""; issueTitle.setText(Html.fromHtml(issueNumber_ + " " + singleIssue.getTitle())); String cleanIssueDescription = singleIssue.getBody().trim(); @@ -406,7 +406,7 @@ public class IssueDetailActivity extends BaseActivity { ImageView assigneesView = new ImageView(getApplicationContext()); - Picasso.get().load(singleIssue.getAssignees().get(i).getAvatar_url()).placeholder(R.drawable.loader_animated).transform(new RoundedTransformation(8, 0)).resize(100, 100).centerCrop().into(assigneesView); + PicassoService.getInstance(ctx).get().load(singleIssue.getAssignees().get(i).getAvatar_url()).placeholder(R.drawable.loader_animated).transform(new RoundedTransformation(8, 0)).resize(100, 100).centerCrop().into(assigneesView); assigneesLayout.addView(assigneesView); assigneesView.setLayoutParams(params1); diff --git a/app/src/main/java/org/mian/gitnex/activities/LoginActivity.java b/app/src/main/java/org/mian/gitnex/activities/LoginActivity.java index e07f7807..a8df33b4 100644 --- a/app/src/main/java/org/mian/gitnex/activities/LoginActivity.java +++ b/app/src/main/java/org/mian/gitnex/activities/LoginActivity.java @@ -31,6 +31,7 @@ import org.mian.gitnex.models.UserInfo; import org.mian.gitnex.models.UserTokens; import org.mian.gitnex.util.AppUtil; import org.mian.gitnex.util.TinyDB; +import java.net.NoRouteToHostException; import java.net.URI; import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; @@ -727,7 +728,7 @@ public class LoginActivity extends BaseActivity implements View.OnClickListener public void onFailure(@NonNull Call createUserToken, @NonNull Throwable t) { Log.e("onFailure-token", t.toString()); - + } }); diff --git a/app/src/main/java/org/mian/gitnex/activities/MainActivity.java b/app/src/main/java/org/mian/gitnex/activities/MainActivity.java index 51a2e540..7156d378 100644 --- a/app/src/main/java/org/mian/gitnex/activities/MainActivity.java +++ b/app/src/main/java/org/mian/gitnex/activities/MainActivity.java @@ -1,5 +1,6 @@ package org.mian.gitnex.activities; +import android.app.Activity; import android.content.ActivityNotFoundException; import android.content.Context; import android.content.Intent; @@ -22,8 +23,8 @@ import android.view.View; import android.widget.ImageView; import android.widget.TextView; import com.squareup.picasso.NetworkPolicy; -import com.squareup.picasso.Picasso; import org.mian.gitnex.R; +import org.mian.gitnex.clients.PicassoService; import org.mian.gitnex.clients.RetrofitClient; import org.mian.gitnex.fragments.AboutFragment; import org.mian.gitnex.fragments.ExploreRepositoriesFragment; @@ -101,7 +102,7 @@ public class MainActivity extends BaseActivity implements NavigationView.OnNavig boolean connToInternet = AppUtil.haveNetworkConnection(getApplicationContext()); if(!tinyDb.getBoolean("loggedInMode")) { - logout(); + logout(this, ctx); return; } @@ -207,7 +208,7 @@ public class MainActivity extends BaseActivity implements NavigationView.OnNavig userAvatar = hView.findViewById(R.id.userAvatar); if (!userAvatarNav.equals("")) { - Picasso.get().load(userAvatarNav).networkPolicy(NetworkPolicy.OFFLINE).placeholder(R.drawable.loader_animated).transform(new RoundedTransformation(8, 0)).resize(160, 160).centerCrop().into(userAvatar); + PicassoService.getInstance(ctx).get().load(userAvatarNav).networkPolicy(NetworkPolicy.OFFLINE).placeholder(R.drawable.loader_animated).transform(new RoundedTransformation(8, 0)).resize(160, 160).centerCrop().into(userAvatar); } userAvatar.setOnClickListener(new View.OnClickListener() { @@ -354,7 +355,7 @@ public class MainActivity extends BaseActivity implements NavigationView.OnNavig new SettingsFragment()).commit(); break; case R.id.nav_logout: - logout(); + logout(this, ctx); overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out); break; case R.id.nav_about: @@ -392,15 +393,15 @@ public class MainActivity extends BaseActivity implements NavigationView.OnNavig } } - public void logout() { + public static void logout(Activity activity, Context ctx) { - TinyDB tinyDb = new TinyDB(getApplicationContext()); + TinyDB tinyDb = new TinyDB(ctx.getApplicationContext()); tinyDb.putBoolean("loggedInMode", false); tinyDb.remove("basicAuthPassword"); tinyDb.putBoolean("basicAuthFlag", false); //tinyDb.clear(); - finish(); - startActivity(new Intent(MainActivity.this, LoginActivity.class)); + activity.finish(); + ctx.startActivity(new Intent(ctx, LoginActivity.class)); } @@ -488,7 +489,7 @@ public class MainActivity extends BaseActivity implements NavigationView.OnNavig userAvatar = hView.findViewById(R.id.userAvatar); if (!Objects.requireNonNull(userDetails).getAvatar().equals("")) { - Picasso.get().load(userDetails.getAvatar()).placeholder(R.drawable.loader_animated).transform(new RoundedTransformation(8, 0)).resize(160, 160).centerCrop().into(userAvatar); + PicassoService.getInstance(ctx).get().load(userDetails.getAvatar()).placeholder(R.drawable.loader_animated).transform(new RoundedTransformation(8, 0)).resize(160, 160).centerCrop().into(userAvatar); } else { userAvatar.setImageResource(R.mipmap.app_logo_round); } diff --git a/app/src/main/java/org/mian/gitnex/adapters/AdminGetUsersAdapter.java b/app/src/main/java/org/mian/gitnex/adapters/AdminGetUsersAdapter.java index 18ef0171..bde801c6 100644 --- a/app/src/main/java/org/mian/gitnex/adapters/AdminGetUsersAdapter.java +++ b/app/src/main/java/org/mian/gitnex/adapters/AdminGetUsersAdapter.java @@ -11,8 +11,8 @@ import android.widget.TextView; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; import com.amulyakhare.textdrawable.TextDrawable; -import com.squareup.picasso.Picasso; import org.mian.gitnex.R; +import org.mian.gitnex.clients.PicassoService; import org.mian.gitnex.helpers.RoundedTransformation; import org.mian.gitnex.models.UserInfo; import java.util.ArrayList; @@ -98,7 +98,7 @@ public class AdminGetUsersAdapter extends RecyclerView.Adapter" + context.getResources().getString(R.string.hash) + issuesModel.getNumber() + ""; diff --git a/app/src/main/java/org/mian/gitnex/adapters/CollaboratorsAdapter.java b/app/src/main/java/org/mian/gitnex/adapters/CollaboratorsAdapter.java index ce6ab11f..29111d51 100644 --- a/app/src/main/java/org/mian/gitnex/adapters/CollaboratorsAdapter.java +++ b/app/src/main/java/org/mian/gitnex/adapters/CollaboratorsAdapter.java @@ -8,8 +8,8 @@ import android.view.ViewGroup; import android.widget.BaseAdapter; import android.widget.ImageView; import android.widget.TextView; -import com.squareup.picasso.Picasso; import org.mian.gitnex.R; +import org.mian.gitnex.clients.PicassoService; import org.mian.gitnex.models.Collaborators; import org.mian.gitnex.helpers.RoundedTransformation; import java.util.List; @@ -77,7 +77,7 @@ public class CollaboratorsAdapter extends BaseAdapter { private void initData(ViewHolder viewHolder, int position) { Collaborators currentItem = collaboratorsList.get(position); - Picasso.get().load(currentItem.getAvatar_url()).placeholder(R.drawable.loader_animated).transform(new RoundedTransformation(8, 0)).resize(180, 180).centerCrop().into(viewHolder.collaboratorAvatar); + PicassoService.getInstance(mCtx).get().load(currentItem.getAvatar_url()).placeholder(R.drawable.loader_animated).transform(new RoundedTransformation(8, 0)).resize(180, 180).centerCrop().into(viewHolder.collaboratorAvatar); if(!currentItem.getFull_name().equals("")) { viewHolder.collaboratorName.setText(currentItem.getFull_name()); diff --git a/app/src/main/java/org/mian/gitnex/adapters/ExploreRepositoriesAdapter.java b/app/src/main/java/org/mian/gitnex/adapters/ExploreRepositoriesAdapter.java index 22ff2ab8..472cef03 100644 --- a/app/src/main/java/org/mian/gitnex/adapters/ExploreRepositoriesAdapter.java +++ b/app/src/main/java/org/mian/gitnex/adapters/ExploreRepositoriesAdapter.java @@ -14,12 +14,12 @@ import androidx.recyclerview.widget.RecyclerView; import com.amulyakhare.textdrawable.TextDrawable; import com.amulyakhare.textdrawable.util.ColorGenerator; import com.google.android.material.bottomsheet.BottomSheetDialog; -import com.squareup.picasso.Picasso; import org.mian.gitnex.R; import org.mian.gitnex.activities.OpenRepoInBrowserActivity; import org.mian.gitnex.activities.RepoDetailActivity; import org.mian.gitnex.activities.RepoStargazersActivity; import org.mian.gitnex.activities.RepoWatchersActivity; +import org.mian.gitnex.clients.PicassoService; import org.mian.gitnex.helpers.RoundedTransformation; import org.mian.gitnex.models.UserRepositories; import org.mian.gitnex.util.TinyDB; @@ -160,7 +160,7 @@ public class ExploreRepositoriesAdapter extends RecyclerView.Adapter issueAssigneeAvatar.setOnClickListener(new ClickListener(context.getResources().getString(R.string.issueCreator) + issuesModel.getUser().getLogin(), context)); } - Picasso.get().load(issuesModel.getUser().getAvatar_url()).placeholder(R.drawable.loader_animated).transform(new RoundedTransformation(8, 0)).resize(120, 120).centerCrop().into(issueAssigneeAvatar); + PicassoService.getInstance(context).get().load(issuesModel.getUser().getAvatar_url()).placeholder(R.drawable.loader_animated).transform(new RoundedTransformation(8, 0)).resize(120, 120).centerCrop().into(issueAssigneeAvatar); String issueNumber_ = "" + context.getResources().getString(R.string.hash) + issuesModel.getNumber() + ""; issueTitle.setText(Html.fromHtml(issueNumber_ + " " + issuesModel.getTitle())); diff --git a/app/src/main/java/org/mian/gitnex/adapters/MembersByOrgAdapter.java b/app/src/main/java/org/mian/gitnex/adapters/MembersByOrgAdapter.java index 345bba32..5428f4aa 100644 --- a/app/src/main/java/org/mian/gitnex/adapters/MembersByOrgAdapter.java +++ b/app/src/main/java/org/mian/gitnex/adapters/MembersByOrgAdapter.java @@ -10,8 +10,8 @@ import android.widget.Filter; import android.widget.Filterable; import android.widget.ImageView; import android.widget.TextView; -import com.squareup.picasso.Picasso; import org.mian.gitnex.R; +import org.mian.gitnex.clients.PicassoService; import org.mian.gitnex.helpers.RoundedTransformation; import org.mian.gitnex.models.UserInfo; import java.util.ArrayList; @@ -83,7 +83,7 @@ public class MembersByOrgAdapter extends BaseAdapter implements Filterable { private void initData(MembersByOrgAdapter.ViewHolder viewHolder, int position) { UserInfo currentItem = membersList.get(position); - Picasso.get().load(currentItem.getAvatar()).placeholder(R.drawable.loader_animated).transform(new RoundedTransformation(8, 0)).resize(120, 120).centerCrop().into(viewHolder.memberAvatar); + PicassoService.getInstance(mCtx).get().load(currentItem.getAvatar()).placeholder(R.drawable.loader_animated).transform(new RoundedTransformation(8, 0)).resize(120, 120).centerCrop().into(viewHolder.memberAvatar); if(!currentItem.getFullname().equals("")) { viewHolder.memberName.setText(currentItem.getFullname()); diff --git a/app/src/main/java/org/mian/gitnex/adapters/MyReposListAdapter.java b/app/src/main/java/org/mian/gitnex/adapters/MyReposListAdapter.java index 3a339e06..237fa9c4 100644 --- a/app/src/main/java/org/mian/gitnex/adapters/MyReposListAdapter.java +++ b/app/src/main/java/org/mian/gitnex/adapters/MyReposListAdapter.java @@ -14,12 +14,12 @@ import android.widget.TextView; import com.amulyakhare.textdrawable.TextDrawable; import com.amulyakhare.textdrawable.util.ColorGenerator; import com.google.android.material.bottomsheet.BottomSheetDialog; -import com.squareup.picasso.Picasso; import org.mian.gitnex.R; import org.mian.gitnex.activities.OpenRepoInBrowserActivity; import org.mian.gitnex.activities.RepoDetailActivity; import org.mian.gitnex.activities.RepoStargazersActivity; import org.mian.gitnex.activities.RepoWatchersActivity; +import org.mian.gitnex.clients.PicassoService; import org.mian.gitnex.helpers.RoundedTransformation; import org.mian.gitnex.models.UserRepositories; import org.mian.gitnex.util.TinyDB; @@ -162,7 +162,7 @@ public class MyReposListAdapter extends RecyclerView.Adapter" + context.getResources().getString(R.string.hash) + prModel.getNumber() + ""; diff --git a/app/src/main/java/org/mian/gitnex/adapters/RepoStargazersAdapter.java b/app/src/main/java/org/mian/gitnex/adapters/RepoStargazersAdapter.java index 9166e933..9b4d6e72 100644 --- a/app/src/main/java/org/mian/gitnex/adapters/RepoStargazersAdapter.java +++ b/app/src/main/java/org/mian/gitnex/adapters/RepoStargazersAdapter.java @@ -9,8 +9,8 @@ import android.view.ViewGroup; import android.widget.BaseAdapter; import android.widget.ImageView; import android.widget.TextView; -import com.squareup.picasso.Picasso; import org.mian.gitnex.R; +import org.mian.gitnex.clients.PicassoService; import org.mian.gitnex.helpers.RoundedTransformation; import org.mian.gitnex.models.UserInfo; import org.mian.gitnex.util.TinyDB; @@ -79,7 +79,7 @@ public class RepoStargazersAdapter extends BaseAdapter { private void initData(RepoStargazersAdapter.ViewHolder viewHolder, int position) { UserInfo currentItem = stargazersList.get(position); - Picasso.get().load(currentItem.getAvatar()).placeholder(R.drawable.loader_animated).transform(new RoundedTransformation(8, 0)).resize(180, 180).centerCrop().into(viewHolder.memberAvatar); + PicassoService.getInstance(mCtx).get().load(currentItem.getAvatar()).placeholder(R.drawable.loader_animated).transform(new RoundedTransformation(8, 0)).resize(180, 180).centerCrop().into(viewHolder.memberAvatar); final TinyDB tinyDb = new TinyDB(mCtx); Typeface myTypeface; diff --git a/app/src/main/java/org/mian/gitnex/adapters/RepoWatchersAdapter.java b/app/src/main/java/org/mian/gitnex/adapters/RepoWatchersAdapter.java index b039399f..0e46e049 100644 --- a/app/src/main/java/org/mian/gitnex/adapters/RepoWatchersAdapter.java +++ b/app/src/main/java/org/mian/gitnex/adapters/RepoWatchersAdapter.java @@ -9,8 +9,8 @@ import android.view.ViewGroup; import android.widget.BaseAdapter; import android.widget.ImageView; import android.widget.TextView; -import com.squareup.picasso.Picasso; import org.mian.gitnex.R; +import org.mian.gitnex.clients.PicassoService; import org.mian.gitnex.helpers.RoundedTransformation; import org.mian.gitnex.models.UserInfo; import org.mian.gitnex.util.TinyDB; @@ -79,7 +79,7 @@ public class RepoWatchersAdapter extends BaseAdapter { private void initData(RepoWatchersAdapter.ViewHolder viewHolder, int position) { UserInfo currentItem = watchersList.get(position); - Picasso.get().load(currentItem.getAvatar()).placeholder(R.drawable.loader_animated).transform(new RoundedTransformation(8, 0)).resize(180, 180).centerCrop().into(viewHolder.memberAvatar); + PicassoService.getInstance(mCtx).get().load(currentItem.getAvatar()).placeholder(R.drawable.loader_animated).transform(new RoundedTransformation(8, 0)).resize(180, 180).centerCrop().into(viewHolder.memberAvatar); final TinyDB tinyDb = new TinyDB(mCtx); Typeface myTypeface; diff --git a/app/src/main/java/org/mian/gitnex/adapters/ReposListAdapter.java b/app/src/main/java/org/mian/gitnex/adapters/ReposListAdapter.java index fce07051..9c83b49c 100644 --- a/app/src/main/java/org/mian/gitnex/adapters/ReposListAdapter.java +++ b/app/src/main/java/org/mian/gitnex/adapters/ReposListAdapter.java @@ -16,12 +16,12 @@ import android.widget.TextView; import com.amulyakhare.textdrawable.TextDrawable; import com.amulyakhare.textdrawable.util.ColorGenerator; import com.google.android.material.bottomsheet.BottomSheetDialog; -import com.squareup.picasso.Picasso; import org.mian.gitnex.R; import org.mian.gitnex.activities.OpenRepoInBrowserActivity; import org.mian.gitnex.activities.RepoDetailActivity; import org.mian.gitnex.activities.RepoStargazersActivity; import org.mian.gitnex.activities.RepoWatchersActivity; +import org.mian.gitnex.clients.PicassoService; import org.mian.gitnex.helpers.RoundedTransformation; import org.mian.gitnex.models.UserRepositories; import org.mian.gitnex.util.TinyDB; @@ -165,7 +165,7 @@ public class ReposListAdapter extends RecyclerView.Adapter 0) { diff --git a/app/src/main/java/org/mian/gitnex/clients/IssuesService.java b/app/src/main/java/org/mian/gitnex/clients/IssuesService.java index e8393d25..98ccf570 100644 --- a/app/src/main/java/org/mian/gitnex/clients/IssuesService.java +++ b/app/src/main/java/org/mian/gitnex/clients/IssuesService.java @@ -1,10 +1,16 @@ package org.mian.gitnex.clients; import android.content.Context; +import android.util.Log; import androidx.annotation.NonNull; +import org.mian.gitnex.helpers.ssl.MemorizingTrustManager; import org.mian.gitnex.util.AppUtil; import java.io.File; import java.io.IOException; +import java.security.SecureRandom; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import javax.net.ssl.X509TrustManager; import okhttp3.Cache; import okhttp3.Interceptor; import okhttp3.OkHttpClient; @@ -20,42 +26,59 @@ import retrofit2.converter.gson.GsonConverterFactory; public class IssuesService { - public static S createService(Class serviceClass, String instanceURL, Context ctx) { + public static S createService(Class serviceClass, String instanceURL, Context ctx) { - final boolean connToInternet = AppUtil.haveNetworkConnection(ctx); - File httpCacheDirectory = new File(ctx.getCacheDir(), "responses"); - int cacheSize = 50 * 1024 * 1024; // 50MB - Cache cache = new Cache(httpCacheDirectory, cacheSize); + final boolean connToInternet = AppUtil.haveNetworkConnection(ctx); + File httpCacheDirectory = new File(ctx.getCacheDir(), "responses"); + int cacheSize = 50 * 1024 * 1024; // 50MB + Cache cache = new Cache(httpCacheDirectory, cacheSize); - HttpLoggingInterceptor logging = new HttpLoggingInterceptor(); - logging.setLevel(HttpLoggingInterceptor.Level.BODY); + HttpLoggingInterceptor logging = new HttpLoggingInterceptor(); + logging.setLevel(HttpLoggingInterceptor.Level.BODY); - OkHttpClient okHttpClient = new OkHttpClient.Builder() - .cache(cache) - //.addInterceptor(logging) - .addInterceptor(new Interceptor() { - @NonNull - @Override public Response intercept(@NonNull Chain chain) throws IOException { - Request request = chain.request(); - if (connToInternet) { - request = request.newBuilder().header("Cache-Control", "public, max-age=" + 60).build(); - } else { - request = request.newBuilder().header("Cache-Control", "public, only-if-cached, max-stale=" + 60 * 60 * 24 * 30).build(); - } - return chain.proceed(request); - } - }) - .build(); + try { - Retrofit.Builder builder = new Retrofit.Builder() - .baseUrl(instanceURL) - .client(okHttpClient) - .addConverterFactory(GsonConverterFactory.create()); + SSLContext sslContext = SSLContext.getInstance("TLS"); - Retrofit retrofit = builder.build(); + MemorizingTrustManager memorizingTrustManager = new MemorizingTrustManager(ctx); + sslContext.init(null, new X509TrustManager[]{memorizingTrustManager}, new SecureRandom()); - return retrofit.create(serviceClass); + OkHttpClient okHttpClient = new OkHttpClient.Builder() + .cache(cache) + //.addInterceptor(logging) + .sslSocketFactory(sslContext.getSocketFactory(), memorizingTrustManager) + .hostnameVerifier(memorizingTrustManager.wrapHostnameVerifier(HttpsURLConnection.getDefaultHostnameVerifier())) + .addInterceptor(new Interceptor() { - } + @NonNull + @Override + public Response intercept(@NonNull Chain chain) throws IOException { + + Request request = chain.request(); + if(connToInternet) { + request = request.newBuilder().header("Cache-Control", "public, max-age=" + 60).build(); + } + else { + request = request.newBuilder().header("Cache-Control", "public, only-if-cached, max-stale=" + 60 * 60 * 24 * 30).build(); + } + return chain.proceed(request); + } + }).build(); + + Retrofit.Builder builder = new Retrofit.Builder() + .baseUrl(instanceURL) + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()); + + Retrofit retrofit = builder.build(); + return retrofit.create(serviceClass); + + } + catch(Exception e) { + Log.e("onFailure", e.toString()); + } + + return null; + } } diff --git a/app/src/main/java/org/mian/gitnex/clients/PicassoService.java b/app/src/main/java/org/mian/gitnex/clients/PicassoService.java new file mode 100644 index 00000000..e759d773 --- /dev/null +++ b/app/src/main/java/org/mian/gitnex/clients/PicassoService.java @@ -0,0 +1,71 @@ +package org.mian.gitnex.clients; + +import android.content.Context; +import android.util.Log; +import com.squareup.picasso.OkHttp3Downloader; +import com.squareup.picasso.Picasso; +import org.mian.gitnex.helpers.ssl.MemorizingTrustManager; +import java.security.SecureRandom; +import java.util.Objects; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import javax.net.ssl.X509TrustManager; +import okhttp3.OkHttpClient; + +/** + * Author anonTree1417 + */ + +public class PicassoService { + + private static PicassoService picassoService; + private Picasso picasso; + + private PicassoService(Context context) { + + Picasso.Builder builder = new Picasso.Builder(context); + + try { + + SSLContext sslContext = SSLContext.getInstance("TLS"); + + MemorizingTrustManager memorizingTrustManager = new MemorizingTrustManager(context); + sslContext.init(null, new X509TrustManager[]{memorizingTrustManager}, new SecureRandom()); + + OkHttpClient.Builder okHttpClient = new OkHttpClient.Builder() + .sslSocketFactory(sslContext.getSocketFactory(), memorizingTrustManager) + .hostnameVerifier(memorizingTrustManager.wrapHostnameVerifier(HttpsURLConnection.getDefaultHostnameVerifier())); + + builder.downloader(new OkHttp3Downloader(okHttpClient.build())); + builder.listener((picasso, uri, exception) -> { + + //Log.e("PicassoService", Objects.requireNonNull(uri.toString())); + //Log.e("PicassoService", exception.toString()); + + }); + + picasso = builder.build(); + + } + catch(Exception e) { + + Log.e("PicassoService", e.toString()); + } + + } + + public Picasso get() { + + return picasso; + } + + public static synchronized PicassoService getInstance(Context context) { + + if(picassoService == null) { + picassoService = new PicassoService(context); + } + + return picassoService; + } + +} diff --git a/app/src/main/java/org/mian/gitnex/clients/PullRequestsService.java b/app/src/main/java/org/mian/gitnex/clients/PullRequestsService.java index 0f5f8ad3..b6bbd632 100644 --- a/app/src/main/java/org/mian/gitnex/clients/PullRequestsService.java +++ b/app/src/main/java/org/mian/gitnex/clients/PullRequestsService.java @@ -1,10 +1,16 @@ package org.mian.gitnex.clients; import android.content.Context; +import android.util.Log; import androidx.annotation.NonNull; +import org.mian.gitnex.helpers.ssl.MemorizingTrustManager; import org.mian.gitnex.util.AppUtil; import java.io.File; import java.io.IOException; +import java.security.SecureRandom; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import javax.net.ssl.X509TrustManager; import okhttp3.Cache; import okhttp3.Interceptor; import okhttp3.OkHttpClient; @@ -20,42 +26,58 @@ import retrofit2.converter.gson.GsonConverterFactory; public class PullRequestsService { - public static S createService(Class serviceClass, String instanceURL, Context ctx) { + public static S createService(Class serviceClass, String instanceURL, Context ctx) { - final boolean connToInternet = AppUtil.haveNetworkConnection(ctx); - File httpCacheDirectory = new File(ctx.getCacheDir(), "responses"); - int cacheSize = 50 * 1024 * 1024; // 50MB - Cache cache = new Cache(httpCacheDirectory, cacheSize); + final boolean connToInternet = AppUtil.haveNetworkConnection(ctx); + File httpCacheDirectory = new File(ctx.getCacheDir(), "responses"); + int cacheSize = 50 * 1024 * 1024; // 50MB + Cache cache = new Cache(httpCacheDirectory, cacheSize); - HttpLoggingInterceptor logging = new HttpLoggingInterceptor(); - logging.setLevel(HttpLoggingInterceptor.Level.BODY); + HttpLoggingInterceptor logging = new HttpLoggingInterceptor(); + logging.setLevel(HttpLoggingInterceptor.Level.BODY); - OkHttpClient okHttpClient = new OkHttpClient.Builder() - .cache(cache) - //.addInterceptor(logging) - .addInterceptor(new Interceptor() { - @NonNull - @Override public Response intercept(@NonNull Chain chain) throws IOException { - Request request = chain.request(); - if (connToInternet) { - request = request.newBuilder().header("Cache-Control", "public, max-age=" + 60).build(); - } else { - request = request.newBuilder().header("Cache-Control", "public, only-if-cached, max-stale=" + 60 * 60 * 24 * 30).build(); - } - return chain.proceed(request); - } - }) - .build(); + try { - Retrofit.Builder builder = new Retrofit.Builder() - .baseUrl(instanceURL) - .client(okHttpClient) - .addConverterFactory(GsonConverterFactory.create()); + SSLContext sslContext = SSLContext.getInstance("TLS"); - Retrofit retrofit = builder.build(); + MemorizingTrustManager memorizingTrustManager = new MemorizingTrustManager(ctx); + sslContext.init(null, new X509TrustManager[]{memorizingTrustManager}, new SecureRandom()); - return retrofit.create(serviceClass); + OkHttpClient okHttpClient = new OkHttpClient.Builder() + .cache(cache) + //.addInterceptor(logging) + .sslSocketFactory(sslContext.getSocketFactory(), memorizingTrustManager) + .hostnameVerifier(memorizingTrustManager.wrapHostnameVerifier(HttpsURLConnection.getDefaultHostnameVerifier())) + .addInterceptor(new Interceptor() { - } + @NonNull + @Override + public Response intercept(@NonNull Chain chain) throws IOException { + + Request request = chain.request(); + if(connToInternet) { + request = request.newBuilder().header("Cache-Control", "public, max-age=" + 60).build(); + } + else { + request = request.newBuilder().header("Cache-Control", "public, only-if-cached, max-stale=" + 60 * 60 * 24 * 30).build(); + } + return chain.proceed(request); + } + }).build(); + + Retrofit.Builder builder = new Retrofit.Builder() + .baseUrl(instanceURL) + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()); + + Retrofit retrofit = builder.build(); + return retrofit.create(serviceClass); + } + catch(Exception e) { + Log.e("onFailure", e.toString()); + } + + return null; + } } diff --git a/app/src/main/java/org/mian/gitnex/clients/RetrofitClient.java b/app/src/main/java/org/mian/gitnex/clients/RetrofitClient.java index 2c97127b..b79fcb1a 100644 --- a/app/src/main/java/org/mian/gitnex/clients/RetrofitClient.java +++ b/app/src/main/java/org/mian/gitnex/clients/RetrofitClient.java @@ -1,17 +1,19 @@ package org.mian.gitnex.clients; import android.content.Context; -import androidx.annotation.NonNull; +import android.util.Log; import org.mian.gitnex.interfaces.ApiInterface; import org.mian.gitnex.interfaces.WebInterface; +import org.mian.gitnex.helpers.ssl.MemorizingTrustManager; import org.mian.gitnex.util.AppUtil; import java.io.File; -import java.io.IOException; +import java.security.SecureRandom; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import javax.net.ssl.X509TrustManager; import okhttp3.Cache; -import okhttp3.Interceptor; import okhttp3.OkHttpClient; import okhttp3.Request; -import okhttp3.Response; import okhttp3.logging.HttpLoggingInterceptor; import retrofit2.Retrofit; import retrofit2.converter.gson.GsonConverterFactory; @@ -23,52 +25,64 @@ import retrofit2.converter.scalars.ScalarsConverterFactory; public class RetrofitClient { - private Retrofit retrofit; + private Retrofit retrofit; - private RetrofitClient(String instanceUrl, Context ctx) { + private RetrofitClient(String instanceUrl, Context ctx) { - final boolean connToInternet = AppUtil.haveNetworkConnection(ctx); - int cacheSize = 50 * 1024 * 1024; // 50MB - File httpCacheDirectory = new File(ctx.getCacheDir(), "responses"); - Cache cache = new Cache(httpCacheDirectory, cacheSize); + final boolean connToInternet = AppUtil.haveNetworkConnection(ctx); + int cacheSize = 50 * 1024 * 1024; // 50MB + File httpCacheDirectory = new File(ctx.getCacheDir(), "responses"); + Cache cache = new Cache(httpCacheDirectory, cacheSize); - HttpLoggingInterceptor logging = new HttpLoggingInterceptor(); - logging.setLevel(HttpLoggingInterceptor.Level.BODY); + HttpLoggingInterceptor logging = new HttpLoggingInterceptor(); + logging.setLevel(HttpLoggingInterceptor.Level.BODY); - OkHttpClient okHttpClient = new OkHttpClient.Builder() - .cache(cache) - //.addInterceptor(logging) - .addInterceptor(new Interceptor() { - @NonNull - @Override public Response intercept(@NonNull Chain chain) throws IOException { - Request request = chain.request(); - if (connToInternet) { - request = request.newBuilder().header("Cache-Control", "public, max-age=" + 60).build(); - } else { - request = request.newBuilder().header("Cache-Control", "public, only-if-cached, max-stale=" + 60 * 60 * 24 * 30).build(); - } - return chain.proceed(request); - } - }) - .build(); + try { - Retrofit.Builder builder = new Retrofit.Builder() - .baseUrl(instanceUrl) - .client(okHttpClient) - .addConverterFactory(ScalarsConverterFactory.create()) - .addConverterFactory(GsonConverterFactory.create()); + SSLContext sslContext = SSLContext.getInstance("TLS"); - retrofit = builder.build(); + MemorizingTrustManager memorizingTrustManager = new MemorizingTrustManager(ctx); + sslContext.init(null, new X509TrustManager[]{memorizingTrustManager}, new SecureRandom()); - } + OkHttpClient.Builder okHttpClient = new OkHttpClient.Builder() + .cache(cache) + //.addInterceptor(logging) + .sslSocketFactory(sslContext.getSocketFactory(), memorizingTrustManager) + .hostnameVerifier(memorizingTrustManager.wrapHostnameVerifier(HttpsURLConnection.getDefaultHostnameVerifier())) + .addInterceptor(chain -> { - public static synchronized RetrofitClient getInstance(String instanceUrl, Context ctx) { - return new RetrofitClient(instanceUrl, ctx); - } + Request request = chain.request(); + if(connToInternet) { + request = request.newBuilder().header("Cache-Control", "public, max-age=" + 60).build(); + } + else { + request = request.newBuilder().header("Cache-Control", "public, only-if-cached, max-stale=" + 60 * 60 * 24 * 30).build(); + } + return chain.proceed(request); + }); - public ApiInterface getApiInterface() { - return retrofit.create(ApiInterface.class); - } + Retrofit.Builder builder = new Retrofit.Builder() + .baseUrl(instanceUrl) + .client(okHttpClient.build()) + .addConverterFactory(ScalarsConverterFactory.create()) + .addConverterFactory(GsonConverterFactory.create()); + + retrofit = builder.build(); + + } + catch(Exception e) { + Log.e("onFailure", e.toString()); + } + + } + + public static synchronized RetrofitClient getInstance(String instanceUrl, Context ctx) { + return new RetrofitClient(instanceUrl, ctx); + } + + public ApiInterface getApiInterface() { + return retrofit.create(ApiInterface.class); + } public WebInterface getWebInterface() { return retrofit.create(WebInterface.class); diff --git a/app/src/main/java/org/mian/gitnex/fragments/OrganizationInfoFragment.java b/app/src/main/java/org/mian/gitnex/fragments/OrganizationInfoFragment.java index bb0b9758..71cda317 100644 --- a/app/src/main/java/org/mian/gitnex/fragments/OrganizationInfoFragment.java +++ b/app/src/main/java/org/mian/gitnex/fragments/OrganizationInfoFragment.java @@ -14,8 +14,8 @@ import android.view.ViewGroup; import android.widget.ImageView; import android.widget.ProgressBar; import android.widget.TextView; -import com.squareup.picasso.Picasso; import org.mian.gitnex.R; +import org.mian.gitnex.clients.PicassoService; import org.mian.gitnex.clients.RetrofitClient; import org.mian.gitnex.helpers.Authorization; import org.mian.gitnex.helpers.RoundedTransformation; @@ -104,7 +104,7 @@ public class OrganizationInfoFragment extends Fragment { if (response.code() == 200) { assert orgInfo != null; - Picasso.get().load(orgInfo.getAvatar_url()).placeholder(R.drawable.loader_animated).transform(new RoundedTransformation(8, 0)).resize(180, 180).centerCrop().into(orgAvatar); + PicassoService.getInstance(ctx).get().load(orgInfo.getAvatar_url()).placeholder(R.drawable.loader_animated).transform(new RoundedTransformation(8, 0)).resize(180, 180).centerCrop().into(orgAvatar); orgDescInfo.setText(orgInfo.getDescription()); orgWebsiteInfo.setText(orgInfo.getWebsite()); orgLocationInfo.setText(orgInfo.getLocation()); diff --git a/app/src/main/java/org/mian/gitnex/fragments/ProfileFragment.java b/app/src/main/java/org/mian/gitnex/fragments/ProfileFragment.java index 43dda832..f8907a8e 100644 --- a/app/src/main/java/org/mian/gitnex/fragments/ProfileFragment.java +++ b/app/src/main/java/org/mian/gitnex/fragments/ProfileFragment.java @@ -18,9 +18,9 @@ import android.view.ViewGroup; import android.widget.ImageView; import android.widget.TextView; import com.google.android.material.tabs.TabLayout; -import com.squareup.picasso.Picasso; import org.mian.gitnex.R; import org.mian.gitnex.activities.MainActivity; +import org.mian.gitnex.clients.PicassoService; import org.mian.gitnex.helpers.RoundedTransformation; import org.mian.gitnex.util.TinyDB; import java.util.Objects; @@ -48,7 +48,7 @@ public class ProfileFragment extends Fragment { TextView userEmail = v.findViewById(R.id.userEmail); userFullName.setText(tinyDb.getString("userFullname")); - Picasso.get().load(tinyDb.getString("userAvatar")).placeholder(R.drawable.loader_animated).transform(new RoundedTransformation(8, 0)).resize(120, 120).centerCrop().into(userAvatar); + PicassoService.getInstance(ctx).get().load(tinyDb.getString("userAvatar")).placeholder(R.drawable.loader_animated).transform(new RoundedTransformation(8, 0)).resize(120, 120).centerCrop().into(userAvatar); userLogin.setText(getString(R.string.usernameWithAt, tinyDb.getString("userLogin"))); userEmail.setText(tinyDb.getString("userEmail")); diff --git a/app/src/main/java/org/mian/gitnex/fragments/SettingsFragment.java b/app/src/main/java/org/mian/gitnex/fragments/SettingsFragment.java index 492af7c4..0c87a21f 100644 --- a/app/src/main/java/org/mian/gitnex/fragments/SettingsFragment.java +++ b/app/src/main/java/org/mian/gitnex/fragments/SettingsFragment.java @@ -13,7 +13,9 @@ import android.widget.LinearLayout; import android.widget.Switch; import android.widget.TextView; import org.mian.gitnex.R; +import org.mian.gitnex.activities.MainActivity; import org.mian.gitnex.helpers.Toasty; +import org.mian.gitnex.helpers.ssl.MemorizingTrustManager; import org.mian.gitnex.util.TinyDB; import java.util.Objects; import androidx.annotation.NonNull; @@ -67,6 +69,7 @@ public class SettingsFragment extends Fragment { LinearLayout homeScreenFrame = v.findViewById(R.id.homeScreenFrame); LinearLayout customFontFrame = v.findViewById(R.id.customFontFrame); LinearLayout themeFrame = v.findViewById(R.id.themeSelectionFrame); + LinearLayout certsFrame = v.findViewById(R.id.certsFrame); Switch issuesSwitch = v.findViewById(R.id.switchIssuesBadge); Switch pdfModeSwitch = v.findViewById(R.id.switchPdfMode); @@ -144,6 +147,28 @@ public class SettingsFragment extends Fragment { pdfModeSwitch.setChecked(false); } + // certs deletion + certsFrame.setOnClickListener(v1 -> { + + AlertDialog.Builder builder = new AlertDialog.Builder(ctx); + builder.setTitle(getResources().getString(R.string.settingsCertsPopupTitle)); + builder.setMessage(getResources().getString(R.string.settingsCertsPopupMessage)); + builder.setPositiveButton(R.string.menuDeleteText, (dialog, which) -> { + + ctx.getSharedPreferences(MemorizingTrustManager.KEYSTORE_NAME, Context.MODE_PRIVATE) + .edit() + .remove(MemorizingTrustManager.KEYSTORE_KEY) + .apply(); + + MainActivity.logout(Objects.requireNonNull(getActivity()), ctx); + + }); + + builder.setNeutralButton(R.string.cancelButton, (dialog, which) -> dialog.dismiss()); + builder.create().show(); + + }); + // issues badge switcher issuesSwitch.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { diff --git a/app/src/main/java/org/mian/gitnex/helpers/ssl/MTMDecision.java b/app/src/main/java/org/mian/gitnex/helpers/ssl/MTMDecision.java new file mode 100644 index 00000000..a0264f39 --- /dev/null +++ b/app/src/main/java/org/mian/gitnex/helpers/ssl/MTMDecision.java @@ -0,0 +1,15 @@ +package org.mian.gitnex.helpers.ssl; + +/** + * Author Georg Lukas, modified by anonTree1417 + */ + +class MTMDecision { + + final static int DECISION_INVALID = 0; + final static int DECISION_ABORT = 1; + final static int DECISION_ALWAYS = 2; + + int state = DECISION_INVALID; + +} diff --git a/app/src/main/java/org/mian/gitnex/helpers/ssl/MemorizingActivity.java b/app/src/main/java/org/mian/gitnex/helpers/ssl/MemorizingActivity.java new file mode 100644 index 00000000..472a9954 --- /dev/null +++ b/app/src/main/java/org/mian/gitnex/helpers/ssl/MemorizingActivity.java @@ -0,0 +1,44 @@ +package org.mian.gitnex.helpers.ssl; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.Intent; +import android.os.Bundle; +import org.mian.gitnex.R; + +/** + * Author Georg Lukas, modified by anonTree1417 + */ + +public class MemorizingActivity extends Activity { + + @Override + public void onCreate(Bundle savedInstanceState) { + + super.onCreate(savedInstanceState); + + Intent intent = getIntent(); + int decisionId = intent.getIntExtra("DECISION_INTENT_ID", MTMDecision.DECISION_INVALID); + int titleId = intent.getIntExtra("DECISION_TITLE_ID", R.string.mtm_accept_cert); + String cert = intent.getStringExtra("DECISION_INTENT_CERT"); + + AlertDialog.Builder builder = new AlertDialog.Builder(MemorizingActivity.this); + builder.setTitle(titleId); + builder.setMessage(cert); + + builder.setPositiveButton(R.string.mtm_decision_always, (dialog, which) -> onSendResult(decisionId, MTMDecision.DECISION_ALWAYS)); + builder.setNeutralButton(R.string.mtm_decision_abort, (dialog, which) -> onSendResult(decisionId, MTMDecision.DECISION_ABORT)); + builder.setOnCancelListener(dialog -> onSendResult(decisionId, MTMDecision.DECISION_ABORT)); + + builder.create().show(); + + } + + private void onSendResult(int decisionId, int decision) { + + MemorizingTrustManager.interactResult(decisionId, decision); + finish(); + + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/mian/gitnex/helpers/ssl/MemorizingTrustManager.java b/app/src/main/java/org/mian/gitnex/helpers/ssl/MemorizingTrustManager.java new file mode 100644 index 00000000..3ee8b045 --- /dev/null +++ b/app/src/main/java/org/mian/gitnex/helpers/ssl/MemorizingTrustManager.java @@ -0,0 +1,670 @@ +package org.mian.gitnex.helpers.ssl; + +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.os.Handler; +import android.util.Base64; +import android.util.SparseArray; +import androidx.core.app.NotificationCompat; +import org.mian.gitnex.R; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertPathValidatorException; +import java.security.cert.Certificate; +import java.security.cert.CertificateEncodingException; +import java.security.cert.CertificateException; +import java.security.cert.CertificateExpiredException; +import java.security.cert.CertificateParsingException; +import java.security.cert.X509Certificate; +import java.text.SimpleDateFormat; +import java.util.Collection; +import java.util.Enumeration; +import java.util.List; +import java.util.Locale; +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.SSLSession; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; + +/** + * Author Georg Lukas, modified by anonTree1417 + */ + +public class MemorizingTrustManager implements X509TrustManager { + private final static int NOTIFICATION_ID = 100509; + + public final static String KEYSTORE_NAME = "keystore"; + public final static String KEYSTORE_KEY = "keystore"; + + private Context context; + private NotificationManager notificationManager; + private static int decisionId = 0; + private static final SparseArray openDecisions = new SparseArray<>(); + + private Handler masterHandler; + private SharedPreferences keyStoreStorage; + private KeyStore appKeyStore; + private X509TrustManager defaultTrustManager; + private X509TrustManager appTrustManager; + + /** + * Creates an instance of the MemorizingTrustManager class that falls back to a custom TrustManager. + *

+ * You need to supply the application context. This has to be one of: + * - Application + * - Activity + * - Service + *

+ * The context is used for file management, to display the dialog / + * notification and for obtaining translated strings. + * + * @param m Context for the application. + * @param defaultTrustManager Delegate trust management to this TM. If null, the user must accept every certificate. + */ + public MemorizingTrustManager(Context m, X509TrustManager defaultTrustManager) { + + init(m); + this.appTrustManager = getTrustManager(appKeyStore); + this.defaultTrustManager = defaultTrustManager; + } + + /** + * Creates an instance of the MemorizingTrustManager class using the system X509TrustManager. + *

+ * You need to supply the application context. This has to be one of: + * - Application + * - Activity + * - Service + *

+ * The context is used for file management, to display the dialog / + * notification and for obtaining translated strings. + * + * @param m Context for the application. + */ + public MemorizingTrustManager(Context m) { + + init(m); + this.appTrustManager = getTrustManager(appKeyStore); + this.defaultTrustManager = getTrustManager(null); + } + + private void init(Context m) { + + context = m; + masterHandler = new Handler(m.getMainLooper()); + notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + + keyStoreStorage = m.getSharedPreferences(KEYSTORE_NAME, Context.MODE_PRIVATE); + appKeyStore = loadAppKeyStore(); + } + + + /** + * Returns a X509TrustManager list containing a new instance of + * TrustManagerFactory. + *

+ * This function is meant for convenience only. You can use it + * as follows to integrate TrustManagerFactory for HTTPS sockets: + * + *

+	 *     SSLContext sc = SSLContext.getInstance("TLS");
+	 *     sc.init(null, MemorizingTrustManager.getInstanceList(this),
+	 *         new java.security.SecureRandom());
+	 *     HttpsURLConnection.setDefaultSSLSocketFactory(sc.getSocketFactory());
+	 * 
+ * + * @param c Activity or Service to show the Dialog / Notification + */ + public static X509TrustManager[] getInstanceList(Context c) { + + return new X509TrustManager[]{new MemorizingTrustManager(c)}; + } + + /** + * Get a list of all certificate aliases stored in MTM. + * + * @return an {@link Enumeration} of all certificates + */ + private Enumeration getCertificates() { + + try { + return appKeyStore.aliases(); + } + catch(KeyStoreException e) { + // this should never happen, however... + throw new RuntimeException(e); + } + } + + /** + * Get a certificate for a given alias. + * + * @param alias the certificate's alias as returned by {@link #getCertificates()}. + * @return the certificate associated with the alias or null if none found. + */ + public Certificate getCertificate(String alias) { + + try { + return appKeyStore.getCertificate(alias); + } + catch(KeyStoreException e) { + // this should never happen, however... + throw new RuntimeException(e); + } + } + + /** + * Removes the given certificate from MTMs key store. + * + *

+ * WARNING: this does not immediately invalidate the certificate. It is + * well possible that (a) data is transmitted over still existing connections or + * (b) new connections are created using TLS renegotiation, without a new cert + * check. + *

+ * + * @param alias the certificate's alias as returned by {@link #getCertificates()}. + * @throws KeyStoreException if the certificate could not be deleted. + */ + public void deleteCertificate(String alias) throws KeyStoreException { + + appKeyStore.deleteEntry(alias); + keyStoreUpdated(); + } + + /** + * Creates a new hostname verifier supporting user interaction. + * + *

This method creates a new {@link HostnameVerifier} that is bound to + * the given instance of {@link MemorizingTrustManager}, and leverages an + * existing {@link HostnameVerifier}. The returned verifier performs the + * following steps, returning as soon as one of them succeeds: + * /p> + *

    + *
  1. Success, if the wrapped defaultVerifier accepts the certificate.
  2. + *
  3. Success, if the server certificate is stored in the keystore under the given hostname.
  4. + *
  5. Ask the user and return accordingly.
  6. + *
  7. Failure on exception.
  8. + *
+ * + * @param defaultVerifier the {@link HostnameVerifier} that should perform the actual check + * @return a new hostname verifier using the MTM's key store + * @throws IllegalArgumentException if the defaultVerifier parameter is null + */ + public HostnameVerifier wrapHostnameVerifier(final HostnameVerifier defaultVerifier) { + + if(defaultVerifier == null) { + throw new IllegalArgumentException("The default verifier may not be null"); + } + + return new MemorizingHostnameVerifier(defaultVerifier); + } + + private X509TrustManager getTrustManager(KeyStore ks) { + + try { + TrustManagerFactory tmf = TrustManagerFactory.getInstance("X509"); + tmf.init(ks); + for(TrustManager t : tmf.getTrustManagers()) { + if(t instanceof X509TrustManager) { + return (X509TrustManager) t; + } + } + } + catch(Exception e) { + e.printStackTrace(); + } + + return null; + } + + private KeyStore loadAppKeyStore() { + + KeyStore keyStore; + + try { + keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + } + catch(KeyStoreException e) { + e.printStackTrace(); + return null; + } + + try { + keyStore.load(null, null); + } + catch(NoSuchAlgorithmException | CertificateException | IOException e) { + e.printStackTrace(); + } + + String keystore = keyStoreStorage.getString(KEYSTORE_KEY, null); + + if(keystore != null) { + ByteArrayInputStream inputStream = new ByteArrayInputStream(Base64.decode(keystore, Base64.DEFAULT)); + + try { + keyStore.load(inputStream, "MTM".toCharArray()); + inputStream.close(); + } + catch(Exception e) { + e.printStackTrace(); + } + } + + return keyStore; + } + + private void storeCert(String alias, Certificate cert) { + + try { + appKeyStore.setCertificateEntry(alias, cert); + } + catch(KeyStoreException e) { + e.printStackTrace(); + return; + } + + keyStoreUpdated(); + } + + private void storeCert(X509Certificate cert) { + + storeCert(cert.getSubjectDN().toString(), cert); + } + + private void keyStoreUpdated() { + // reload appTrustManager + appTrustManager = getTrustManager(appKeyStore); + + // store KeyStore to shared preferences + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + + try { + appKeyStore.store(byteArrayOutputStream, "MTM".toCharArray()); + byteArrayOutputStream.flush(); + byteArrayOutputStream.close(); + + keyStoreStorage.edit().putString(KEYSTORE_KEY, Base64.encodeToString(byteArrayOutputStream.toByteArray(), Base64.DEFAULT)).apply(); + } + catch(Exception e) { + e.printStackTrace(); + } + } + + // if the certificate is stored in the app key store, it is considered "known" + private boolean isCertKnown(X509Certificate cert) { + + try { + return appKeyStore.getCertificateAlias(cert) != null; + } + catch(KeyStoreException e) { + return false; + } + } + + private static boolean isExpiredException(Throwable e) { + + do { + if(e instanceof CertificateExpiredException) { + return true; + } + e = e.getCause(); + } while(e != null); + + return false; + } + + private static boolean isPathException(Throwable e) { + + do { + if(e instanceof CertPathValidatorException) { + return true; + } + + e = e.getCause(); + } while(e != null); + + return false; + } + + private void checkCertTrusted(X509Certificate[] chain, String authType, boolean isServer) throws CertificateException { + try { + + if(isServer) { + appTrustManager.checkServerTrusted(chain, authType); + } + else { + appTrustManager.checkClientTrusted(chain, authType); + } + } + catch(CertificateException ae) { + // if the cert is stored in our appTrustManager, we ignore expiredness + if(isExpiredException(ae) || isCertKnown(chain[0])) { + return; + } + + try { + if(defaultTrustManager == null) { + throw ae; + } + if(isServer) { + defaultTrustManager.checkServerTrusted(chain, authType); + } + else { + defaultTrustManager.checkClientTrusted(chain, authType); + } + } + catch(CertificateException e) { + interactCert(chain, authType, e); + } + } + } + + public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { + + checkCertTrusted(chain, authType, false); + } + + public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException { + + checkCertTrusted(chain, authType, true); + } + + public X509Certificate[] getAcceptedIssuers() { + + return defaultTrustManager.getAcceptedIssuers(); + } + + private static int createDecisionId(MTMDecision d) { + + int myId; + synchronized(openDecisions) { + myId = decisionId; + openDecisions.put(myId, d); + decisionId += 1; + } + return myId; + } + + private static String hexString(byte[] data) { + + StringBuilder si = new StringBuilder(); + + for(int i = 0; i < data.length; i++) { + si.append(String.format("%02x", data[i])); + if(i < data.length - 1) { + si.append(":"); + } + } + + return si.toString(); + } + + private static String certHash(final X509Certificate cert, String digest) { + + try { + MessageDigest md = MessageDigest.getInstance(digest); + md.update(cert.getEncoded()); + return hexString(md.digest()); + } + catch(CertificateEncodingException | NoSuchAlgorithmException e) { + return e.getMessage(); + } + } + + private static void certDetails(StringBuilder stringBuilder, X509Certificate c) { + + SimpleDateFormat validityDateFormater = new SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()); + + stringBuilder.append("\n") + .append(c.getSubjectDN().toString()) + .append("\n") + .append(validityDateFormater.format(c.getNotBefore())) + .append(" - ") + .append(validityDateFormater.format(c.getNotAfter())) + .append("\nSHA-256: ") + .append(certHash(c, "SHA-256")) + .append("\nSHA-1: ") + .append(certHash(c, "SHA-1")) + .append("\nSigned by: ") + .append(c.getIssuerDN().toString()) + .append("\n"); + } + + private String certChainMessage(final X509Certificate[] chain, CertificateException cause) { + + Throwable e = cause; + StringBuilder stringBuilder = new StringBuilder(); + + if(isPathException(e)) { + stringBuilder.append(context.getString(R.string.mtm_trust_anchor)); + } + else if(isExpiredException(e)) { + stringBuilder.append(context.getString(R.string.mtm_cert_expired)); + } + else { + // get to the cause + while(e.getCause() != null) { + e = e.getCause(); + } + + stringBuilder.append(e.getLocalizedMessage()); + } + + stringBuilder.append("\n\n"); + stringBuilder.append(context.getString(R.string.mtm_connect_anyway)); + stringBuilder.append("\n\n"); + stringBuilder.append(context.getString(R.string.mtm_cert_details)); + + for(X509Certificate c : chain) { + certDetails(stringBuilder, c); + } + + return stringBuilder.toString(); + } + + private String hostNameMessage(X509Certificate cert, String hostname) { + + StringBuilder stringBuilder = new StringBuilder(); + + stringBuilder.append(context.getString(R.string.mtm_hostname_mismatch, hostname)); + stringBuilder.append("\n\n"); + + try { + Collection> sans = cert.getSubjectAlternativeNames(); + + if(sans == null) { + stringBuilder.append(cert.getSubjectDN()); + stringBuilder.append("\n"); + } + else { + for(List altName : sans) { + Object name = altName.get(1); + if(name instanceof String) { + stringBuilder.append("["); + stringBuilder.append(altName.get(0)); + stringBuilder.append("] "); + stringBuilder.append(name); + stringBuilder.append("\n"); + } + } + } + } + catch(CertificateParsingException e) { + e.printStackTrace(); + stringBuilder.append("\n"); + } + + stringBuilder.append("\n"); + stringBuilder.append(context.getString(R.string.mtm_connect_anyway)); + stringBuilder.append("\n\n"); + stringBuilder.append(context.getString(R.string.mtm_cert_details)); + certDetails(stringBuilder, cert); + return stringBuilder.toString(); + } + + /** + * Reflectively call + * Notification.setLatestEventInfo(Context, CharSequence, CharSequence, PendingIntent) + * since it was remove in Android API level 23. + */ + private static void setLatestEventInfoReflective(Notification notification, Context context, CharSequence mtmNotification, CharSequence certName, PendingIntent call) { + + Method setLatestEventInfo; + + try { + setLatestEventInfo = notification.getClass().getMethod("setLatestEventInfo", Context.class, CharSequence.class, CharSequence.class, PendingIntent.class); + } + catch(NoSuchMethodException e) { + throw new IllegalStateException(e); + } + + try { + setLatestEventInfo.invoke(notification, context, mtmNotification, certName, call); + } + catch(IllegalAccessException | IllegalArgumentException | InvocationTargetException e) { + throw new IllegalStateException(e); + } + } + + private void startActivityNotification(Intent intent, int decisionId, String certName) { + + final PendingIntent call = PendingIntent.getActivity(context, 0, intent, 0); + final String mtmNotification = context.getString(R.string.mtm_notification); + + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, "ssl") + .setSmallIcon(android.R.drawable.ic_lock_lock) + .setContentTitle(mtmNotification) + .setContentText(certName) + .setTicker(certName) + .setContentIntent(call) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_HIGH); + + notificationManager.notify(NOTIFICATION_ID + decisionId, builder.build()); + } + + private int interact(final String message, final int titleId) { + MTMDecision choice = new MTMDecision(); + final int myId = createDecisionId(choice); + + masterHandler.post(new Runnable() { + + public void run() { + + Intent intent = new Intent(context, MemorizingActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT); + intent.putExtra("DECISION_INTENT_ID", myId); + intent.putExtra("DECISION_INTENT_CERT", message); + intent.putExtra("DECISION_TITLE_ID", titleId); + + try { + context.startActivity(intent); + } + catch(Exception e) { + startActivityNotification(intent, myId, message); + } + } + }); + + try { + synchronized(choice) { + choice.wait(); + } + } + catch(InterruptedException e) { + e.printStackTrace(); + } + + return choice.state; + } + + private void interactCert(final X509Certificate[] chain, String authType, CertificateException cause) throws CertificateException { + + if(interact(certChainMessage(chain, cause), R.string.mtm_accept_cert) == MTMDecision.DECISION_ALWAYS) { + storeCert(chain[0]); // only store the server cert, not the whole chain + } else { + throw (cause); + } + } + + private boolean interactHostname(X509Certificate cert, String hostname) { + + if(interact(hostNameMessage(cert, hostname), R.string.mtm_accept_servername) == MTMDecision.DECISION_ALWAYS) { + storeCert(hostname, cert); + return true; + } + + return false; + } + + static void interactResult(int decisionId, int choice) { + + MTMDecision d; + + synchronized(openDecisions) { + d = openDecisions.get(decisionId); + openDecisions.remove(decisionId); + } + + if(d == null) { + return; + } + + synchronized(d) { + d.state = choice; + d.notify(); + } + } + + class MemorizingHostnameVerifier implements HostnameVerifier { + + private HostnameVerifier defaultVerifier; + + MemorizingHostnameVerifier(HostnameVerifier wrapped) { + + defaultVerifier = wrapped; + } + + @Override + public boolean verify(String hostname, SSLSession session) { + // if the default verifier accepts the hostname, we are done + if(defaultVerifier.verify(hostname, session)) { + return true; + } + + // otherwise, we check if the hostname is an alias for this cert in our keystore + try { + X509Certificate cert = (X509Certificate) session.getPeerCertificates()[0]; + + if(cert.equals(appKeyStore.getCertificate(hostname.toLowerCase(Locale.US)))) { + return true; + } + else { + return interactHostname(cert, hostname); + } + } + catch(Exception e) { + e.printStackTrace(); + return false; + } + } + + } + +} diff --git a/app/src/main/res/drawable/ic_security_24dp.xml b/app/src/main/res/drawable/ic_security_24dp.xml new file mode 100644 index 00000000..6ffc522d --- /dev/null +++ b/app/src/main/res/drawable/ic_security_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/layout/fragment_settings.xml b/app/src/main/res/layout/fragment_settings.xml index 5f6eb145..274ee8a1 100644 --- a/app/src/main/res/layout/fragment_settings.xml +++ b/app/src/main/res/layout/fragment_settings.xml @@ -33,7 +33,7 @@ layout="@layout/layout_settings_fileview"/> + + + + diff --git a/app/src/main/res/layout/layout_settings_security.xml b/app/src/main/res/layout/layout_settings_security.xml new file mode 100644 index 00000000..c06342fe --- /dev/null +++ b/app/src/main/res/layout/layout_settings_security.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 641bc7dc..4a173dc5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -92,6 +92,7 @@ 1- Choose the correct protocol(https or http). \n2- Enter Gitea url e.g: try.gitea.io. \n3- If you have enabled 2FA for your account, enter the code in the OTP Code field. \n4- For HTTP basic auth use USERNAME@DOMAIN.COM in the URL field. Wrong username/password :// + Couldn\'t connect to host. Please check your URL or port for any errors. It is not recommended to use HTTP protocol unless you are testing on local network. Malformed JSON was received. Server response was not successful. Instance URL is required @@ -245,6 +246,10 @@ Translation + Security + Delete Trusted Certificates + Delete Trusted Certificates? + Are you sure to delete any manually trusted certificate or hostname? \n\nYou will also be logged out. Date & Time Settings saved Language @@ -569,4 +574,18 @@ Committed by %1$s View Commits Changelog + + + Certificate Verification + Accept Unknown Certificate? + The server certificate is not signed by a known Certificate Authority. + The server certificate is expired. + Accept Mismatching Server Name? + Server could not authenticate as \"%s\". The certificate is only valid for: + Do you want to connect anyway? + Certificate details: + Trust + Once + Abort +