Android: get login working

This commit is contained in:
Pieter Vander Vennet 2024-12-31 19:55:08 +01:00
parent 00c233a2eb
commit 88c76498b6
16 changed files with 199 additions and 171 deletions

View file

@ -6,6 +6,7 @@ import Constants from "../../Models/Constants"
import { Changes } from "./Changes"
import { Utils } from "../../Utils"
import FeaturePropertiesStore from "../FeatureSource/Actors/FeaturePropertiesStore"
import { AndroidPolyfill } from "../Web/AndroidPolyfill"
export interface ChangesetTag {
key: string
@ -52,7 +53,7 @@ export class ChangesetHandler {
| { addAlias: (id0: string, id1: string) => void }
| undefined,
changes: Changes,
reportError: (e: string | Error, extramessage: string) => void
reportError: (e: string | Error, extramessage: string) => void,
) {
this.osmConnection = osmConnection
this._reportError = reportError
@ -113,7 +114,7 @@ export class ChangesetHandler {
private async UploadWithNew(
generateChangeXML: (csid: number, remappings: Map<string, string>) => string,
openChangeset: UIEventSource<number>,
extraMetaTags: ChangesetTag[]
extraMetaTags: ChangesetTag[],
) {
const csId = await this.OpenChangeset(extraMetaTags)
openChangeset.setData(csId)
@ -121,7 +122,7 @@ export class ChangesetHandler {
console.log(
"Opened a new changeset (openChangeset.data is undefined):",
changeset,
extraMetaTags
extraMetaTags,
)
const changes = await this.UploadChange(csId, changeset)
const hasSpecialMotivationChanges = ChangesetHandler.rewriteMetaTags(extraMetaTags, changes)
@ -144,7 +145,7 @@ export class ChangesetHandler {
public async UploadChangeset(
generateChangeXML: (csid: number, remappings: Map<string, string>) => string,
extraMetaTags: ChangesetTag[],
openChangeset: UIEventSource<number>
openChangeset: UIEventSource<number>,
): Promise<void> {
if (
!extraMetaTags.some((tag) => tag.key === "comment") ||
@ -179,13 +180,13 @@ export class ChangesetHandler {
try {
const rewritings = await this.UploadChange(
csId,
generateChangeXML(csId, this._remappings)
generateChangeXML(csId, this._remappings),
)
const rewrittenTags = this.RewriteTagsOf(
extraMetaTags,
rewritings,
oldChangesetMeta
oldChangesetMeta,
)
await this.UpdateTags(csId, rewrittenTags)
return // We are done!
@ -196,7 +197,7 @@ export class ChangesetHandler {
} catch (e) {
this._reportError(
e,
"While getting metadata from a changeset " + openChangeset.data
"While getting metadata from a changeset " + openChangeset.data,
)
}
}
@ -224,7 +225,7 @@ export class ChangesetHandler {
console.warn(
"Could not open/upload changeset due to ",
e,
"trying again with a another fresh changeset "
"trying again with a another fresh changeset ",
)
openChangeset.setData(undefined)
@ -250,7 +251,7 @@ export class ChangesetHandler {
uid: number // User ID
changes_count: number
tags: any
}
},
): ChangesetTag[] {
// Note: extraMetaTags is where all the tags are collected into
@ -387,7 +388,7 @@ export class ChangesetHandler {
tag.key !== undefined &&
tag.value !== undefined &&
tag.key !== "" &&
tag.value !== ""
tag.value !== "",
)
const metadata = tags.map((kv) => `<tag k="${kv.key}" v="${escapeHtml(kv.value)}"/>`)
const content = [`<osm><changeset>`, metadata, `</changeset></osm>`].join("")
@ -398,10 +399,16 @@ export class ChangesetHandler {
const usedGps = this.changes.state["currentUserLocation"]?.features?.data?.length > 0
const hasMorePrivacy = !!this.changes.state?.featureSwitches?.featureSwitchMorePrivacy?.data
const setSourceAsSurvey = !hasMorePrivacy && usedGps
let shell = ""
let host = `${window.location.origin}${window.location.pathname}`
if (AndroidPolyfill.inAndroid.data) {
shell = " (Android)"
host = "https://mapcomplete.org/" + window.location.pathname
}
return [
["created_by", `MapComplete ${Constants.vNumber}`],
["created_by", `MapComplete ${Constants.vNumber}${shell}`],
["locale", Locale.language.data],
["host", `${window.location.origin}${window.location.pathname}`], // Note: deferred changes might give a different hostpath then the theme with which the changes were made
["host", host], // Note: deferred changes might give a different hostpath then the theme with which the changes were made
["source", setSourceAsSurvey ? "survey" : undefined],
["imagery", this.changes.state["backgroundLayer"]?.data?.id],
].map(([key, value]) => ({
@ -427,7 +434,7 @@ export class ChangesetHandler {
const csId = await this.osmConnection.put(
"changeset/create",
[`<osm><changeset>`, metadata, `</changeset></osm>`].join(""),
{ "Content-Type": "text/xml" }
{ "Content-Type": "text/xml" },
)
return Number(csId)
}
@ -437,12 +444,12 @@ export class ChangesetHandler {
*/
private async UploadChange(
changesetId: number,
changesetXML: string
changesetXML: string,
): Promise<Map<string, string>> {
const response = await this.osmConnection.post<XMLDocument>(
"changeset/" + changesetId + "/upload",
changesetXML,
{ "Content-Type": "text/xml" }
{ "Content-Type": "text/xml" },
)
const changes = this.parseUploadChangesetResponse(response)
console.log("Uploaded changeset ", changesetId)

View file

@ -5,41 +5,70 @@ import { Utils } from "../../Utils"
import { LocalStorageSource } from "../Web/LocalStorageSource"
import { AuthConfig } from "./AuthConfig"
import Constants from "../../Models/Constants"
import { AndroidPolyfill } from "../Web/AndroidPolyfill"
import { Feature, Point } from "geojson"
import { AndroidPolyfill } from "../Web/AndroidPolyfill"
import { QueryParameters } from "../Web/QueryParameters"
interface OsmUserInfo {
id: number
display_name: string
account_created: string
description: string
contributor_terms: { agreed: boolean }
roles: []
changesets: { count: number }
traces: { count: number }
blocks: { received: { count: number; active: number } }
}
export default class UserDetails {
public loggedIn = false
public name = "Not logged in"
public uid: number
public csCount = 0
public img?: string
public unreadMessages = 0
public totalMessages: number = 0
public home: { lon: number; lat: number }
public backend: string
public account_created: string
public tracesCount: number = 0
public description: string
public languages: string[]
constructor(backend: string) {
this.backend = backend
"id": number,
"display_name": string,
"account_created": string,
"description": string,
"contributor_terms": {
"agreed": boolean,
"pd": boolean
},
"img": {
"href": string,
},
"roles": string[],
"changesets": {
"count": number
},
"traces": {
"count": number
},
"blocks": {
"received": {
"count": number,
"active": number
}
},
"home": {
"lat": number,
"lon": number,
"zoom": number
},
"languages": string[],
"messages": {
"received": {
"count": number,
"unread": number
},
"sent": {
"count": number
}
}
}
export default interface UserDetails {
loggedIn: boolean
name: string
uid: number
csCount: number
img?: string
unreadMessages: number
totalMessages: number
home?: { lon: number; lat: number }
backend: string
account_created: string
tracesCount: number
description?: string
languages: string[]
}
export type OsmServiceState = "online" | "readonly" | "offline" | "unknown" | "unreachable"
interface CapabilityResult {
@ -77,7 +106,10 @@ interface CapabilityResult {
export class OsmConnection {
public auth: osmAuth
public userDetails: UIEventSource<UserDetails>
/**
* Details of the currently logged-in user; undefined if not logged in
*/
public userDetails: UIEventSource<UserDetails | undefined>
public isLoggedIn: Store<boolean>
public gpxServiceIsOnline: UIEventSource<OsmServiceState> = new UIEventSource<OsmServiceState>(
"unknown",
@ -93,7 +125,6 @@ export class OsmConnection {
public readonly _oauth_config: AuthConfig
private readonly _dryRun: Store<boolean>
private readonly fakeUser: boolean
private _onLoggedIn: ((userDetails: UserDetails) => void)[] = []
private readonly _iframeMode: boolean
private readonly _singlePage: boolean
private isChecking = false
@ -129,10 +160,7 @@ export class OsmConnection {
this._oauth_config.oauth_secret = import.meta.env.VITE_OSM_OAUTH_SECRET
}
this.userDetails = new UIEventSource<UserDetails>(
new UserDetails(this._oauth_config.url),
"userDetails",
)
this.userDetails = new UIEventSource<UserDetails>(undefined, "userDetails")
if (options.fakeUser) {
const ud = this.userDetails.data
ud.csCount = 5678
@ -146,25 +174,21 @@ export class OsmConnection {
"The 'fake-user' is a URL-parameter which allows to test features without needing an OSM account or even internet connection."
this.loadingStatus.setData("logged-in")
}
this.UpdateCapabilities()
this.updateCapabilities()
this.isLoggedIn = this.userDetails.map(
(user) =>
user.loggedIn &&
!!user &&
(this.apiIsOnline.data === "unknown" || this.apiIsOnline.data === "online"),
[this.apiIsOnline],
)
this.isLoggedIn.addCallback((isLoggedIn) => {
if (this.userDetails.data.loggedIn == false && isLoggedIn == true) {
// We have an inconsistency: the userdetails say we _didn't_ log in, but this actor says we do
// This means someone attempted to toggle this; so we attempt to login!
this.AttemptLogin()
}
})
this._dryRun = options.dryRun ?? new UIEventSource<boolean>(false)
this.updateAuthObject()
this.createAuthObject()
AndroidPolyfill.inAndroid.addCallback(() => {
this.createAuthObject()
})
if (!this.fakeUser) {
this.CheckForMessagesContinuously()
}
@ -209,9 +233,6 @@ export class OsmConnection {
return <UIEventSource<T>>this.preferencesHandler.getPreference(key, defaultValue, prefix)
}
public OnLoggedIn(action: (userDetails: UserDetails) => void) {
this._onLoggedIn.push(action)
}
public LogOut() {
this.auth.logout()
@ -233,7 +254,7 @@ export class OsmConnection {
}
public AttemptLogin() {
this.UpdateCapabilities()
this.updateCapabilities()
if (this.loadingStatus.data !== "logged-in") {
// Stay 'logged-in' if we are already logged in; this simply means we are checking for messages
this.loadingStatus.setData("loading")
@ -245,87 +266,63 @@ export class OsmConnection {
}
console.log("Trying to log in...")
this.updateAuthObject()
LocalStorageSource.get("location_before_login").setData(
Utils.runningFromConsole ? undefined : window.location.href,
)
this.auth.xhr(
{
method: "GET",
path: "/api/0.6/user/details",
},
(err, details: XMLDocument) => {
if (err != null) {
console.log("Could not login due to:", err)
this.loadingStatus.setData("error")
if (err.status == 401) {
console.log("Clearing tokens...")
// Not authorized - our token probably got revoked
this.auth.logout()
this.LogOut()
} else {
console.log("Other error. Status:", err.status)
this.apiIsOnline.setData("unreachable")
}
return
}
if (details == null) {
this.loadingStatus.setData("error")
return
}
this.auth.authenticate((err, result) => {
if (!err) {
this.loadUserInfo()
}
})
// details is an XML DOM of user details
const userInfo = details.getElementsByTagName("user")[0]
}
const data = this.userDetails.data
data.loggedIn = true
console.log("Login completed, userinfo is ", userInfo)
data.name = userInfo.getAttribute("display_name")
data.account_created = userInfo.getAttribute("account_created")
data.uid = Number(userInfo.getAttribute("id"))
data.languages = Array.from(
userInfo.getElementsByTagName("languages")[0].getElementsByTagName("lang"),
).map((l) => l.textContent)
data.csCount = Number.parseInt(
userInfo.getElementsByTagName("changesets")[0].getAttribute("count") ?? "0",
)
data.tracesCount = Number.parseInt(
userInfo.getElementsByTagName("traces")[0].getAttribute("count") ?? "0",
)
private async loadUserInfo() {
try {
const result = await this.interact("user/details.json")
if (result === null) {
this.loadingStatus.setData("error")
return
}
const data = <{
"version": "0.6",
"license": "http://opendatacommons.org/licenses/odbl/1-0/",
"user": OsmUserInfo
}>JSON.parse(result)
const user = data.user
const userdetails: UserDetails = {
uid: user.id,
name: user.display_name,
csCount: user.changesets.count,
description: user.description,
loggedIn: true,
backend: this.Backend(),
home: user.home,
languages: user.languages,
totalMessages: user.messages.received?.count ?? 0,
img: user.img?.href,
account_created: user.account_created,
tracesCount: user.traces.count,
unreadMessages: user.messages.received?.unread ?? 0,
}
console.log("Login completed, userinfo is ", userdetails)
this.userDetails.set(userdetails)
this.loadingStatus.setData("logged-in")
data.img = undefined
const imgEl = userInfo.getElementsByTagName("img")
if (imgEl !== undefined && imgEl[0] !== undefined) {
data.img = imgEl[0].getAttribute("href")
}
const description = userInfo.getElementsByTagName("description")
if (description !== undefined && description[0] !== undefined) {
data.description = description[0]?.innerHTML
}
const homeEl = userInfo.getElementsByTagName("home")
if (homeEl !== undefined && homeEl[0] !== undefined) {
const lat = parseFloat(homeEl[0].getAttribute("lat"))
const lon = parseFloat(homeEl[0].getAttribute("lon"))
data.home = { lat: lat, lon: lon }
}
this.loadingStatus.setData("logged-in")
const messages = userInfo
.getElementsByTagName("messages")[0]
.getElementsByTagName("received")[0]
data.unreadMessages = parseInt(messages.getAttribute("unread"))
data.totalMessages = parseInt(messages.getAttribute("count"))
this.userDetails.ping()
for (const action of this._onLoggedIn) {
action(this.userDetails.data)
}
this._onLoggedIn = []
},
)
} catch (err) {
console.log("Could not login due to:", err)
this.loadingStatus.setData("error")
if (err.status == 401) {
console.log("Clearing tokens...")
// Not authorized - our token probably got revoked
this.auth.logout()
this.LogOut()
} else {
console.log("Other error. Status:", err.status)
this.apiIsOnline.setData("unreachable")
}
}
}
/**
@ -339,7 +336,7 @@ export class OsmConnection {
*/
public async interact(
path: string,
method: "GET" | "POST" | "PUT" | "DELETE",
method: "GET" | "POST" | "PUT" | "DELETE" = "GET",
header?: Record<string, string>,
content?: string,
allowAnonymous: boolean = false,
@ -359,6 +356,11 @@ export class OsmConnection {
throw "Could not interact with OSM:" + possibleResult["error"]
}
if (!this.auth.authenticated()) {
console.trace("Not authenticated")
await Utils.waitFor(10000)
}
return new Promise((ok, error) => {
connection.xhr(
{
@ -504,7 +506,7 @@ export class OsmConnection {
(options.filename ?? "gpx_track_mapcomplete_" + new Date().toISOString()) +
"\"\r\nContent-Type: application/gpx+xml",
}
user
const boundary = "987654"
let body = ""
@ -563,19 +565,29 @@ export class OsmConnection {
this.auth.authenticate(() => {
// Fully authed at this point
console.log("Authentication successful!")
const oauth_token = window.localStorage.getItem(this._oauth_config.url + "oauth2_access_token")
const oauth_token = QueryParameters.GetQueryParameter("oauth_token", undefined).data ?? window.localStorage.getItem(this._oauth_config.url + "oauth2_access_token")
const previousLocation = LocalStorageSource.get("location_before_login")
callback(previousLocation.data, oauth_token)
})
}
private updateAuthObject() {
private async loginAndroidPolyfill() {
const token = await AndroidPolyfill.requestLoginCodes()
console.log("Got login token!", token)
localStorage.setItem("https://www.openstreetmap.orgoauth2_access_token", token)
if (this.auth.authenticated()) {
console.log("Logged in!")
}
await this.loadUserInfo()
}
private createAuthObject() {
let redirect_uri = Utils.runningFromConsole
? "https://mapcomplete.org/land.html"
: window.location.protocol + "//" + window.location.host + "/land.html"
if (AndroidPolyfill.inAndroid.data) {
redirect_uri = "https://app.mapcomplete.org/land.html"
AndroidPolyfill.requestLoginCodes(this)
}
this.auth = new osmAuth({
client_id: this._oauth_config.oauth_client_id,
@ -586,9 +598,12 @@ export class OsmConnection {
* However, this breaks in iframes so we open a popup in that case
*/
singlepage: !this._iframeMode && !AndroidPolyfill.inAndroid.data,
auto: true,
auto: false,
apiUrl: this._oauth_config.api_url ?? this._oauth_config.url,
})
if (AndroidPolyfill.inAndroid.data) {
this.loginAndroidPolyfill() // NO AWAIT!
}
}
private CheckForMessagesContinuously() {
@ -611,9 +626,10 @@ export class OsmConnection {
return
}
Stores.Chronic(60 * 5 * 1000).addCallback(() => {
// Check for new messages every 5 minutes
if (this.isLoggedIn.data) {
try {
this.AttemptLogin()
this.loadUserInfo()
} catch (e) {
console.log("Could not login due to", e)
}
@ -621,11 +637,11 @@ export class OsmConnection {
})
}
private UpdateCapabilities(): void {
private updateCapabilities(): void {
if (this.fakeUser) {
return
}
this.FetchCapabilities().then(({ api, gpx }) => {
this.fetchCapabilities().then(({ api, gpx }) => {
this.apiIsOnline.setData(api)
this.gpxServiceIsOnline.setData(gpx)
})
@ -646,7 +662,8 @@ export class OsmConnection {
return parsed
}
private async FetchCapabilities(): Promise<{
/**Does not use the OSM-auth object*/
private async fetchCapabilities(): Promise<{
api: OsmServiceState
gpx: OsmServiceState
database: OsmServiceState
@ -656,7 +673,7 @@ export class OsmConnection {
}
try {
const result = await Utils.downloadJson<CapabilityResult>(
this.Backend() + "/api/0.6/capabilities.json"
this.Backend() + "/api/0.6/capabilities.json",
)
if (result?.api?.status === undefined) {
console.log("Something went wrong:", result)

View file

@ -32,7 +32,7 @@ export class OsmPreferences {
this.auth = auth
this._fakeUser = fakeUser
this.osmConnection = osmConnection
osmConnection.OnLoggedIn(() => {
osmConnection.userDetails.addCallbackAndRunD(() => {
this.loadBulkPreferences()
return true
})

View file

@ -7,6 +7,7 @@ import themeOverview from "../../assets/generated/theme_overview.json"
import LayerSearch from "./LayerSearch"
import SearchUtils from "./SearchUtils"
import { OsmConnection } from "../Osm/OsmConnection"
import { AndroidPolyfill } from "../Web/AndroidPolyfill"
type ThemeSearchScore = {
theme: MinimalThemeInformation
@ -82,7 +83,7 @@ export default class ThemeSearch {
}
let linkPrefix = `${path}/${layout.id.toLowerCase()}.html?`
if (location.hostname === "localhost" || location.hostname === "127.0.0.1") {
if ((location.hostname === "localhost" && !AndroidPolyfill.inAndroid.data) || location.hostname === "127.0.0.1") {
linkPrefix = `${path}/theme.html?layout=${layout.id}&`
}

View file

@ -182,9 +182,7 @@ export default class FeatureSwitchState extends OsmConnectionFeatureSwitches {
let testingDefaultValue = false
if (
!Utils.runningFromConsole &&
(location.hostname === "localhost" || location.hostname === "127.0.0.1") &&
!Constants.osmAuthConfig.url.startsWith("https://master.apis.dev.openstreetmap.org")
!Constants.osmAuthConfig.url.startsWith("https://master.apis.dev.openstreetmap.org") && (location.hostname === "127.0.0.1") && !Utils.runningFromConsole
) {
testingDefaultValue = true
}

View file

@ -51,13 +51,11 @@ export class AndroidPolyfill {
this.backfillGeolocation(this.databridgePlugin)
}
public static async requestLoginCodes(osmConnection: OsmConnection) {
public static async requestLoginCodes() {
const result = await DatabridgePluginSingleton.request<{oauth_token: string}>({ key: "request:login" })
const token: string = result.value.oauth_token
console.log("AndroidPolyfill: received code and state; trying to pass them to the oauth lib",token)
const auth = osmConnection.auth.bootstrapToken(token, (err, result) => {
console.log("AndroidPolyFill: bootstraptoken returned", JSON.stringify({err, result}))
})
console.log("AndroidPolyfill: received oauth_token; trying to pass them to the oauth lib",token)
return token
}
}

View file

@ -41,7 +41,7 @@
const tu = Translations.t.general
const tr = Translations.t.general.morescreen
let userLanguages = osmConnection.userDetails.map((ud) => ud.languages)
let userLanguages = osmConnection.userDetails.mapD((ud) => ud.languages)
let search: UIEventSource<string | undefined> = new UIEventSource<string>("")
let searchStable = search.stabilized(100)
@ -58,7 +58,7 @@
hiddenThemes.filter(
(theme) =>
knownIds.indexOf(theme.id) >= 0 ||
state.osmConnection.userDetails.data.name === "Pieter Vander Vennet"
state.osmConnection.userDetails.data?.name === "Pieter Vander Vennet"
)
)

View file

@ -14,7 +14,7 @@
josmState.stabilized(15000).addCallbackD(() => josmState.setData(undefined))
const showButton = state.osmConnection.userDetails.map(
(ud) => ud.loggedIn && ud.csCount >= Constants.userJourney.historyLinkVisible
(ud) => ud?.csCount >= Constants.userJourney.historyLinkVisible
)
function openJosm() {

View file

@ -44,7 +44,7 @@
const imageInfo = await panoramax.imageInfo(image.id)
let reporter_email: string = undefined
const userdetails = state.userRelatedState.osmConnection.userDetails
if (userdetails.data.loggedIn) {
if (!userdetails.data) {
reporter_email = userdetails.data.name + "@openstreetmap.org"
}

View file

@ -55,7 +55,7 @@ export class DeleteFlowState {
if (ud === undefined) {
return undefined
}
if (!ud.loggedIn) {
if (!this._osmConnection.isLoggedIn.data) {
return false
}
return (

View file

@ -23,7 +23,7 @@
$: tagsExplanation = tags?.asHumanString(true, false, currentProperties)
</script>
{#if !userDetails || $userDetails.loggedIn}
{#if !userDetails}
<div class="break-words" style="word-break: break-word">
{#if tags === undefined}
<slot name="no-tags"><Tr cls="subtle" t={Translations.t.general.noTagsSelected} /></slot>

View file

@ -1,6 +1,5 @@
import SvelteUIElement from "./UI/Base/SvelteUIElement"
import Test from "./UI/Test.svelte"
import { OsmConnection } from "./Logic/Osm/OsmConnection"
new Test({
target: document.getElementById("maindiv"),
})
new OsmConnection().interact("user/details.json").then(r => console.log(">>>", r))