WIP: [core][android] Display profile picture in profile page #8701

Draft
RedAuburn wants to merge 1 commit from RedAuburn/profile-image into master
10 changed files with 151 additions and 56 deletions

View file

@ -9,6 +9,7 @@
#include "editor/osm_auth.hpp"
#include "editor/server_api.hpp"
#include "editor/profile_picture.hpp"
namespace
{
@ -86,7 +87,7 @@ Java_app_organicmaps_editor_OsmOAuth_nativeGetOsmUsername(JNIEnv * env, jclass,
UserPreferences prefs;
if (LoadOsmUserPreferences(jni::ToNativeString(env, oauthToken), prefs))
return jni::ToJavaString(env, prefs.m_displayName);
return nullptr;
return jstring();
}
JNIEXPORT jint JNICALL
@ -99,12 +100,12 @@ Java_app_organicmaps_editor_OsmOAuth_nativeGetOsmChangesetsCount(JNIEnv * env, j
}
JNIEXPORT jstring JNICALL
Java_app_organicmaps_editor_OsmOAuth_nativeGetOsmProfilePictureUrl(JNIEnv * env, jclass, jstring oauthToken)
Java_app_organicmaps_editor_OsmOAuth_nativeGetOsmProfilePicturePath(JNIEnv * env, jclass, jstring oauthToken)
{
UserPreferences prefs;
if (LoadOsmUserPreferences(jni::ToNativeString(env, oauthToken), prefs))
return jni::ToJavaString(env, prefs.m_imageUrl);
return nullptr;
LoadOsmUserPreferences(jni::ToNativeString(env, oauthToken), prefs);
std::string img_path = editor::ProfilePicture::download(prefs.m_imageUrl);
return jni::ToJavaString(env, img_path);
}
JNIEXPORT jstring JNICALL

View file

@ -2,18 +2,15 @@ package app.organicmaps.editor;
import android.content.Context;
import android.content.SharedPreferences;
import android.graphics.Bitmap;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.Size;
import androidx.annotation.WorkerThread;
import androidx.fragment.app.FragmentManager;
import java.util.Map;
import app.organicmaps.MwmApplication;
import app.organicmaps.util.NetworkPolicy;
public final class OsmOAuth
{
@ -66,15 +63,29 @@ public final class OsmOAuth
return MwmApplication.prefs(context).getString(PREF_OSM_OAUTH2_TOKEN, "");
}
public static String getUsername(@NonNull Context context)
public static String getUsername(@NonNull Context context, boolean internet_allowed)
{
return MwmApplication.prefs(context).getString(PREF_OSM_USERNAME, "");
final SharedPreferences prefs = MwmApplication.prefs(context);
if (internet_allowed)
{
final String token = getAuthToken(context);
final String username = OsmOAuth.nativeGetOsmUsername(token);
if(username != null && !username.isEmpty())
prefs.edit().putString(PREF_OSM_USERNAME, username).apply();
}
return prefs.getString(PREF_OSM_USERNAME, "Error: should be unreachable");
}
public static Bitmap getProfilePicture(@NonNull Context context)
public static String getProfilePicturePath(@NonNull Context context, boolean internet_allowed)
{
//TODO(HB): load and store image in cache here
return null;
String token = "";
if (internet_allowed)
token = getAuthToken(context);
biodranik commented 2024-10-16 18:21:47 +00:00 (Migrated from github.com)
Review
  1. We don't use python_variable_naming, but camelCase.
  2. Introducing a separate getCachedUsername() method would be clearer.
1. We don't use python_variable_naming, but camelCase. 2. Introducing a separate getCachedUsername() method would be clearer.
// If token is empty, will only return cached image
return nativeGetOsmProfilePicturePath(token);
}
public static void setAuthorization(@NonNull Context context, String oauthToken, String username)
@ -97,7 +108,21 @@ public final class OsmOAuth
public static String getHistoryUrl(@NonNull Context context)
{
return nativeGetHistoryUrl(getUsername(context));
return nativeGetHistoryUrl(getUsername(context, false));
}
public static int getOsmChangesetsCount(@NonNull Context context, boolean internet_allowed) {
final SharedPreferences prefs = MwmApplication.prefs(context);
if (internet_allowed) {
final String token = getAuthToken(context);
final int editsCount = OsmOAuth.nativeGetOsmChangesetsCount(token);
if(editsCount > 0)
prefs.edit().putInt(PREF_OSM_CHANGESETS_COUNT, editsCount).apply();
}
return prefs.getInt(PREF_OSM_CHANGESETS_COUNT, 0);
}
/*
@ -127,7 +152,7 @@ public final class OsmOAuth
@WorkerThread
@Nullable
public static native String nativeGetOsmProfilePictureUrl(String oauthToken);
public static native String nativeGetOsmProfilePicturePath(String oauthToken);
@WorkerThread
@NonNull
@ -138,22 +163,4 @@ public final class OsmOAuth
*/
@WorkerThread
private static native int nativeGetOsmChangesetsCount(String oauthToken);
@WorkerThread
public static int getOsmChangesetsCount(@NonNull Context context, @NonNull FragmentManager fm) {
final int[] editsCount = {-1};
NetworkPolicy.checkNetworkPolicy(fm, policy -> {
if (!policy.canUseNetwork())
return;
final String token = getAuthToken(context);
editsCount[0] = OsmOAuth.nativeGetOsmChangesetsCount(token);
});
final SharedPreferences prefs = MwmApplication.prefs(context);
if (editsCount[0] < 0)
return prefs.getInt(PREF_OSM_CHANGESETS_COUNT, 0);
prefs.edit().putInt(PREF_OSM_CHANGESETS_COUNT, editsCount[0]).apply();
return editsCount[0];
}
}

View file

@ -1,7 +1,7 @@
package app.organicmaps.editor;
import android.content.Intent;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
@ -15,16 +15,17 @@ import androidx.annotation.Nullable;
import app.organicmaps.R;
import app.organicmaps.base.BaseMwmToolbarFragment;
import app.organicmaps.util.NetworkPolicy;
import app.organicmaps.util.UiUtils;
import app.organicmaps.util.Utils;
import app.organicmaps.util.concurrency.ThreadPool;
import app.organicmaps.util.concurrency.UiThread;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import java.text.NumberFormat;
public class ProfileFragment extends BaseMwmToolbarFragment
{
private View mUserInfoBlock;
private TextView mEditsSent;
private TextView mProfileName;
private ImageView mProfileImage;
@ -50,43 +51,56 @@ public class ProfileFragment extends BaseMwmToolbarFragment
{
View logoutButton = getToolbarController().getToolbar().findViewById(R.id.logout);
logoutButton.setOnClickListener((v) -> logout());
mUserInfoBlock = view.findViewById(R.id.block_user_info);
View mUserInfoBlock = view.findViewById(R.id.block_user_info);
mProfileInfoLoading = view.findViewById(R.id.user_profile_loading);
mEditsSent = mUserInfoBlock.findViewById(R.id.user_sent_edits);
mProfileName = mUserInfoBlock.findViewById(R.id.user_profile_name);
mProfileImage = mUserInfoBlock.findViewById(R.id.user_profile_image);
view.findViewById(R.id.about_osm).setOnClickListener((v) -> Utils.openUrl(requireActivity(), getString(R.string.osm_wiki_about_url)));
view.findViewById(R.id.osm_history).setOnClickListener((v) -> Utils.openUrl(requireActivity(), OsmOAuth.getHistoryUrl(requireContext())));
}
private void refreshViews()
{
// If logged in
if (OsmOAuth.isAuthorized(requireContext()))
{
// Update the number of uploaded changesets from OSM.
ThreadPool.getWorker().execute(() -> {
if (mEditsSent.getText().equals(""))
{
UiUtils.show(mProfileInfoLoading);
UiUtils.hide(mUserInfoBlock);
}
final int profileEditCount = OsmOAuth.getOsmChangesetsCount(requireContext(), getParentFragmentManager());
final String profileUsername = OsmOAuth.getUsername(requireContext());
final Bitmap profilePicture = OsmOAuth.getProfilePicture(requireContext());
// Get/Display cached values first
final int cachedProfileEditCount = OsmOAuth.getOsmChangesetsCount(requireContext(), false);
final String cachedProfilePicture = OsmOAuth.getProfilePicturePath(requireContext(), false);
final String cachedProfileUsername = OsmOAuth.getUsername(requireContext(), false);
UiThread.run(() -> {
mEditsSent.setText(NumberFormat.getInstance().format(profileEditCount));
mProfileName.setText(profileUsername);
mEditsSent.setText(NumberFormat.getInstance().format(cachedProfileEditCount));
mProfileName.setText(cachedProfileUsername);
// Use generic image if user has no profile picture or it failed to load.
if (profilePicture != null)
mProfileImage.setImageBitmap(profilePicture);
if (!cachedProfilePicture.isEmpty())
mProfileImage.setImageBitmap(BitmapFactory.decodeFile(cachedProfilePicture));
else
mProfileImage.setImageResource(R.drawable.profile_generic);
});
biodranik commented 2024-10-16 18:24:53 +00:00 (Migrated from github.com)
Review
    final View userInfoBlock = view.findViewById(R.id.block_user_info);
```suggestion final View userInfoBlock = view.findViewById(R.id.block_user_info); ```
UiUtils.show(mUserInfoBlock);
UiUtils.hide(mProfileInfoLoading);
// Then try to cache/display online values
NetworkPolicy.checkNetworkPolicy(getParentFragmentManager(), policy -> {
if (policy.canUseNetwork())
{
UiUtils.show(mProfileInfoLoading);
final int newProfileEditCount = OsmOAuth.getOsmChangesetsCount(requireContext(), true);
final String newProfileUsername = OsmOAuth.getUsername(requireContext(), true);
final String newProfilePicture = OsmOAuth.getProfilePicturePath(requireContext(), true);
UiThread.run(() -> {
mEditsSent.setText(NumberFormat.getInstance().format(newProfileEditCount));
mProfileName.setText(newProfileUsername);
// Needed in case user removed picture online, to
if (!newProfilePicture.isEmpty())
mProfileImage.setImageBitmap(BitmapFactory.decodeFile(newProfilePicture));
UiUtils.hide(mProfileInfoLoading);
});
}
});
});
}

View file

@ -83,7 +83,8 @@ public class SettingsPrefsFragment extends BaseXmlSettingsFragment
final Preference pref = getPreference(getString(R.string.pref_osm_profile));
if (OsmOAuth.isAuthorized(requireContext()))
{
final String username = OsmOAuth.getUsername(requireContext());
// Only update profile when visiting profile page
final String username = OsmOAuth.getUsername(requireContext(), false);
pref.setSummary(username);
}
else

View file

@ -49,7 +49,7 @@
android:layout_width="100dp"
android:layout_height="match_parent"
android:importantForAccessibility="no"
app:srcCompat="@drawable/profile_generic" />
tools:src="@drawable/profile_generic" />
<LinearLayout
biodranik commented 2024-10-16 18:48:37 +00:00 (Migrated from github.com)
Review

Why is this change needed?

Why is this change needed?
android:id="@+id/text"
android:layout_marginStart="@dimen/margin_base"

View file

@ -78,6 +78,7 @@
#define WORLD_COASTS_FILE_NAME "WorldCoasts"
#define SETTINGS_FILE_NAME "settings.ini"
#define PROFILE_PICTURE_FILENAME "profile_picture"
biodranik commented 2024-10-16 18:48:09 +00:00 (Migrated from github.com)
Review

Why it should be in defines.hpp?

Why it should be in defines.hpp?
#define MARKETING_SETTINGS_FILE_NAME "marketing_settings.ini"
#define SEARCH_CATEGORIES_FILE_NAME "categories.txt"

View file

@ -26,6 +26,8 @@ set(SRC
osm_auth.hpp
osm_editor.cpp
osm_editor.hpp
profile_picture.cpp
profile_picture.hpp
server_api.cpp
server_api.hpp
ui2oh.cpp

View file

@ -0,0 +1,54 @@
#include "editor/profile_picture.hpp"
#include "platform/remote_file.hpp"
#include "platform/platform.hpp"
#include "platform/settings.hpp"
#include "filesystem"
// Example pic_url: https://www.openstreetmap.org/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MzM1MjcxMDUsInB1ciI6ImJsb2JfaWQifX0=--957a2a99111c97d4b9f4181003c3e69713a221c6/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDAsMTAwXX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--473f0190a9e741bb16565db85fe650d7b0a9ee69/cat.png
// Example hash: 473f0190a9e741bb16565db85fe650d7b0a9ee69
namespace editor
{
std::string ProfilePicture::download(const std::string& pic_url)
biodranik commented 2024-10-16 18:41:50 +00:00 (Migrated from github.com)
Review

Is there any generic "download file" api already somewhere? If no, it would be better to add a generic "download a file" API, with an optional hint of expected MIME file type if necessary, so it can be reused to download anything.

Is there any generic "download file" api already somewhere? If no, it would be better to add a generic "download a file" API, with an optional hint of expected MIME file type if necessary, so it can be reused to download anything.
{
std::string image_path = GetPlatform().WritablePathForFile(PROFILE_PICTURE_FILENAME);
bool image_exists = std::filesystem::exists(image_path);
if(!pic_url.empty()) // If internet available
{
if(pic_url != "none")
biodranik commented 2024-10-16 18:47:49 +00:00 (Migrated from github.com)
Review

Using a lot of logic here looks cumbersome, it may be cleaner to split it. Maybe:

  • Upper level getter retrieves an image
    -- if there's a cached image, then it is returned
    -- if no cached image, it is downloaded
    --- if download fails, it will be repeated next time, a stub image is used.
    --- if user doesn't have a picture, this state is cached and generic image is used.
    --- image is cached and returned
Using a lot of logic here looks cumbersome, it may be cleaner to split it. Maybe: - Upper level getter retrieves an image -- if there's a cached image, then it is returned -- if no cached image, it is downloaded --- if download fails, it will be repeated next time, a stub image is used. --- if user doesn't have a picture, this state is cached and generic image is used. --- image is cached and returned
{
// Get new hash (hash start always includes '=--' but is usually '==--')
const int hash_start = static_cast<int>(pic_url.rfind("=--") + 3);
const int length = static_cast<int>(pic_url.rfind('/') - hash_start);
std::string new_hash = pic_url.substr(hash_start, length);
// Get cached hash
std::string current_hash;
settings::StringStorage::Instance().GetValue("ProfilePictureHash", current_hash);
// Download new image
if (new_hash != current_hash || !image_exists)
{
settings::StringStorage::Instance().SetValue("ProfilePictureHash", std::move(new_hash));
platform::RemoteFile remoteFile(pic_url);
remoteFile.Download(image_path);
image_exists = true;
}
}
else
{
// User has removed profile picture online
settings::StringStorage::Instance().DeleteKeyAndValue("ProfilePictureHash");
std::filesystem::remove(image_path);
biodranik commented 2024-10-16 18:43:20 +00:00 (Migrated from github.com)
Review

Is std::filesystem available in the iOS?

Is std::filesystem available in the iOS?
image_exists = false;
}
}
if(image_exists)
return image_path;
else
return {};
}
}

View file

@ -0,0 +1,14 @@
#pragma once
#include <string>
namespace editor
{
class ProfilePicture
biodranik commented 2024-10-16 18:39:24 +00:00 (Migrated from github.com)
Review

Can UserPreferences class be used instead of introducing this wrapper?

Can UserPreferences class be used instead of introducing this wrapper?
{
public:
// Returns downloaded image or cached image if one exists.
// if not, an empty string is returned.
static std::string download(const std::string& pic_url);
biodranik commented 2024-10-16 18:40:12 +00:00 (Migrated from github.com)
Review

There should be a cpp code style doc.

    static std::string download(std::string const & pic_url);
There should be a cpp code style doc. ```suggestion static std::string download(std::string const & pic_url); ```
};
}

View file

@ -167,8 +167,9 @@ UserPreferences ServerApi06::GetUserPreferences() const
pref.m_id = user.attribute("id").as_ullong();
pref.m_displayName = user.attribute("display_name").as_string();
pref.m_accountCreated = base::StringToTimestamp(user.attribute("account_created").as_string());
pref.m_imageUrl = user.child("img").attribute("href").as_string();
pref.m_changesets = user.child("changesets").attribute("count").as_uint();
// Return "none if profile has no 'img', to differentiate from no network
pref.m_imageUrl = user.child("img")? user.child("img").attribute("href").as_string() : "none";
biodranik commented 2024-10-16 18:37:58 +00:00 (Migrated from github.com)
Review

This function should throw if there's a network error. Is "none" here really needed? Can an empty string be used instead?

This function should throw if there's a network error. Is "none" here really needed? Can an empty string be used instead?
return pref;
}