diff --git a/android/app/build.gradle b/android/app/build.gradle index e30241f364..8a62742121 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -58,4 +58,4 @@ try { } } catch(Exception e) { logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work") -} +} \ No newline at end of file diff --git a/locales/en.yml b/locales/en.yml index d7824afe8e..e535bfcf91 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -90,6 +90,8 @@ cannot-test-app-some: Cannot test app something wrong happened change: Change changed-password-suc: Password changed successfully channel: Channel +channel-ab-testing: Enable AB testing +channel-ab-testing-percentage: Percentage of users recving secondary version channel-create: Create channel channel-deleted: Channel deleted channel-invit: Type email to invite @@ -131,6 +133,7 @@ device: Device device-id: Device ID devices: Devices devices-using-this-b: Devices using this bundle +disable-ab-testing: Disabled AB testing disable-auto-downgra: Disable auto downgrade under native disable-auto-upgrade: Disable auto upgrade above major discord: Discord @@ -141,6 +144,7 @@ dont-have-an-account: Don’t have an account? download: Download email: Email email-address: Email address +enabled-ab-testing: Enabled AB testing encrypted: Encrypted bundles enter-your-email-add: Enter your email address and we'll send you a link to reset your password. enter-your-new-passw: Enter your new password and confirm diff --git a/locales/pl.yml b/locales/pl.yml index 5b84e1f261..ef437ce032 100644 --- a/locales/pl.yml +++ b/locales/pl.yml @@ -5,6 +5,10 @@ Filters: Filtry Override: Nadpisanie Storage: Składowanie account: Konto +channel-ab-testing: Włącz testowanie AB +channel-ab-testing-percentage: Procent użytkowników którzy otrzymają drugą wersję +disable-ab-testing: Wyłączono testy AB +enabled-ab-testing: Włączono testy AB account-error: Błąd podczas aktualizowania konta account-password-error: Wystąpił błąd, spróbuj ponownie account-password-heading: Zmień moje hasło diff --git a/src/pages/app/p/[p]/bundle/[bundle].vue b/src/pages/app/p/[p]/bundle/[bundle].vue index 19af469b17..57b8c8b38c 100644 --- a/src/pages/app/p/[p]/bundle/[bundle].vue +++ b/src/pages/app/p/[p]/bundle/[bundle].vue @@ -30,6 +30,7 @@ const version = ref() const channels = ref<(Database['public']['Tables']['channels']['Row'])[]>([]) const channel = ref<(Database['public']['Tables']['channels']['Row'])>() const version_meta = ref() +const secondaryChannel = ref(false) async function copyToast(text: string) { copy(text) @@ -62,8 +63,10 @@ async function getChannels() { // search if the bundle is used in a channel channels.value.forEach((chan) => { const v: number = chan.version as any - if (version.value && v === version.value.id) + if (version.value && (v === version.value.id || version.value.id === chan.secondVersion)) { channel.value = chan + secondaryChannel.value = (version.value.id === chan.secondVersion) + } }) } @@ -103,28 +106,97 @@ async function setChannel(channel: Database['public']['Tables']['channels']['Row .eq('id', channel.id) } +async function setSecondChannel(channel: Database['public']['Tables']['channels']['Row'], id: number) { + return supabase + .from('channels') + .update({ + secondVersion: id, + }) + .eq('id', channel.id) +} + async function ASChannelChooser() { if (!version.value) return const buttons = [] + + // This makes sure that A and B cannot be selected on the same time + const commonAbHandler = async (chan: Database['public']['Tables']['channels']['Row'] | undefined, ab: 'a' | 'b') => { + if (!chan) + return + + const aSelected = version?.value?.id === (chan.version as any) + const bSelected = version?.value?.id === (chan.secondVersion as any) + + if (aSelected && ab === 'b') { + const id = await getUnknowBundleId() + if (!id) + return + + setChannel(chan, id) + } + else if (bSelected && ab === 'a') { + const id = await getUnknowBundleId() + if (!id) + return + + setSecondChannel(chan, id) + } + } + + const normalHandler = async (chan: Database['public']['Tables']['channels']['Row']) => { + if (!version.value) + return + try { + await setChannel(chan, version.value.id) + await getChannels() + } + catch (error) { + console.error(error) + toast.error(t('cannot-test-app-some')) + } + } + + const secondHandler = async (chan: Database['public']['Tables']['channels']['Row']) => { + if (!version.value) + return + try { + await setSecondChannel(chan, version.value.id) + await getChannels() + } + catch (error) { + console.error(error) + toast.error(t('cannot-test-app-some')) + } + } + for (const chan of channels.value) { const v: number = chan.version as any - buttons.push({ - text: chan.name, - selected: version.value.id === v, - handler: async () => { - if (!version.value) - return - try { - await setChannel(chan, version.value.id) - await getChannels() - } - catch (error) { - console.error(error) - toast.error(t('cannot-test-app-some')) - } - }, - }) + if (!chan.enableAbTesting) { + buttons.push({ + text: chan.name, + selected: version.value.id === v, + handler: async () => { await normalHandler(chan) }, + }) + } + else { + buttons.push({ + text: `${chan.name}-A`, + selected: version.value.id === v, + handler: async () => { + await commonAbHandler(channel.value, 'a') + await normalHandler(chan) + }, + }) + buttons.push({ + text: `${chan.name}-B`, + selected: version.value.id === chan.secondVersion, + handler: async () => { + await commonAbHandler(channel.value, 'b') + await secondHandler(chan) + }, + }) + } } buttons.push({ text: t('button-cancel'), @@ -181,7 +253,11 @@ async function openChannel() { const id = await getUnknowBundleId() if (!id) return - await setChannel(channel.value, id) + if (!secondaryChannel.value) + await setChannel(channel.value, id) + else + await setSecondChannel(channel.value, id) + await getChannels() } catch (error) { @@ -319,7 +395,8 @@ function hideString(str: string) { - + + diff --git a/src/pages/app/p/[p]/channel/[channel].vue b/src/pages/app/p/[p]/channel/[channel].vue index 740609a878..15e3a30f6d 100644 --- a/src/pages/app/p/[p]/channel/[channel].vue +++ b/src/pages/app/p/[p]/channel/[channel].vue @@ -4,9 +4,11 @@ import { useI18n } from 'vue-i18n' import { useRoute, useRouter } from 'vue-router' import { kList, kListItem, + kRange, kToggle, } from 'konsta/vue' import { toast } from 'vue-sonner' +import debounce from 'lodash.debounce' import { useSupabase } from '~/services/supabase' import { formatDate } from '~/services/date' import { useMainStore } from '~/stores/main' @@ -21,6 +23,7 @@ import { urlToAppId } from '~/services/conversion' interface Channel { version: Database['public']['Tables']['app_versions']['Row'] + secondVersion: Database['public']['Tables']['app_versions']['Row'] } const router = useRouter() const displayStore = useDisplayStore() @@ -34,6 +37,7 @@ const loading = ref(true) const deviceIds = ref([]) const channel = ref() const ActiveTab = ref('info') +const secondaryVersionPercentage = ref(50) const tabs: Tab[] = [ { @@ -60,10 +64,21 @@ const tabs: Tab[] = [ function openBundle() { if (!channel.value) return + if (channel.value.version.name === 'unknown') + return console.log('openBundle', channel.value.version.id) router.push(`/app/p/${route.params.p}/bundle/${channel.value.version.id}`) } +function openSecondBundle() { + if (!channel.value) + return + if (channel.value.secondVersion.name === 'unknown') + return + console.log('openBundle', channel.value.version.id) + router.push(`/app/p/${route.params.p}/bundle/${channel.value.secondVersion.id}`) +} + async function getDeviceIds() { if (!channel.value) return @@ -108,7 +123,13 @@ async function getChannel() { disableAutoUpdateToMajor, ios, android, - updated_at + updated_at, + enableAbTesting, + secondaryVersionPercentage, + secondVersion ( + name, + id + ) `) .eq('id', id.value) .single() @@ -116,7 +137,12 @@ async function getChannel() { console.error('no channel', error) return } - channel.value = data as Database['public']['Tables']['channels']['Row'] & Channel + + channel.value = data as unknown as Database['public']['Tables']['channels']['Row'] & Channel + secondaryVersionPercentage.value = (data.secondaryVersionPercentage * 100) | 0 + + // Conversion of type '{ id: number; name: string; public: boolean; version: { id: unknown; name: unknown; app_id: unknown; bucket_id: unknown; created_at: unknown; }[]; created_at: string; allow_emulator: boolean; allow_dev: boolean; allow_device_self_set: boolean; ... 7 more ...; secondVersion: number | null; }' to type '{ allow_dev: boolean; allow_device_self_set: boolean; allow_emulator: boolean; android: boolean; app_id: string; beta: boolean; created_at: string; created_by: string; disableAutoUpdateToMajor: boolean; ... 9 more ...; version: number; } & Channel' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first. + // Type '{ id: number; name: string; public: boolean; version: { id: unknown; name: unknown; app_id: unknown; bucket_id: unknown; created_at: unknown; }[]; created_at: string; allow_emulator: boolean; allow_dev: boolean; allow_device_self_set: boolean; ... 7 more ...; secondVersion: number | null; }' is missing the following properties from type '{ allow_dev: boolean; allow_device_self_set: boolean; allow_emulator: boolean; android: boolean; app_id: string; beta: boolean; created_at: string; created_by: string; disableAutoUpdateToMajor: boolean; ... 9 more ...; version: number; }': app_id, beta, created_byts(2352) } catch (error) { console.error(error) @@ -244,6 +270,41 @@ async function openPannel() { } displayStore.showActionSheet = true } + +async function enableAbTesting() { + if (!channel.value) + return + + const val = !channel.value.enableAbTesting + + const { error } = await supabase + .from('channels') + .update({ enableAbTesting: val }) + .eq('id', id.value) + + if (error) { + console.error(error) + } + else { + channel.value.enableAbTesting = val + toast.success(val ? t('enabled-ab-testing') : t('disable-ab-testing')) + } +} + +const debouncedSetSecondaryVersionPercentage = debounce (async (percentage: number) => { + const { error } = await supabase + .from('channels') + .update({ secondaryVersionPercentage: percentage / 100 }) + .eq('id', id.value) + + if (error) + console.error(error) +}, 500, { leading: true, trailing: true, maxWait: 500 }) + +async function setSecondaryVersionPercentage(percentage: number) { + secondaryVersionPercentage.value = percentage + await debouncedSetSecondaryVersionPercentage(percentage) +} + + + + + + diff --git a/src/types/supabase.types.ts b/src/types/supabase.types.ts index c3c14fbc4c..bd85c82cf8 100644 --- a/src/types/supabase.types.ts +++ b/src/types/supabase.types.ts @@ -416,10 +416,13 @@ export interface Database { created_by: string disableAutoUpdateToMajor: boolean disableAutoUpdateUnderNative: boolean + enableAbTesting: boolean id: number ios: boolean name: string public: boolean + secondaryVersionPercentage: number + secondVersion: number | null updated_at: string version: number } @@ -434,10 +437,13 @@ export interface Database { created_by: string disableAutoUpdateToMajor?: boolean disableAutoUpdateUnderNative?: boolean + enableAbTesting?: boolean id?: number ios?: boolean name: string public?: boolean + secondaryVersionPercentage?: number + secondVersion?: number | null updated_at?: string version: number } @@ -452,10 +458,13 @@ export interface Database { created_by?: string disableAutoUpdateToMajor?: boolean disableAutoUpdateUnderNative?: boolean + enableAbTesting?: boolean id?: number ios?: boolean name?: string public?: boolean + secondaryVersionPercentage?: number + secondVersion?: number | null updated_at?: string version?: number } @@ -472,6 +481,12 @@ export interface Database { referencedRelation: "users" referencedColumns: ["id"] }, + { + foreignKeyName: "channels_secondVersion_fkey" + columns: ["secondVersion"] + referencedRelation: "app_versions" + referencedColumns: ["id"] + }, { foreignKeyName: "channels_version_fkey" columns: ["version"] diff --git a/supabase/functions/_utils/supabase.types.ts b/supabase/functions/_utils/supabase.types.ts index c3c14fbc4c..b2f23758d9 100644 --- a/supabase/functions/_utils/supabase.types.ts +++ b/supabase/functions/_utils/supabase.types.ts @@ -416,10 +416,13 @@ export interface Database { created_by: string disableAutoUpdateToMajor: boolean disableAutoUpdateUnderNative: boolean + enableAbTesting: boolean id: number ios: boolean name: string public: boolean + secondaryVersionPercentage: number + secondVersion: number | null updated_at: string version: number } @@ -434,10 +437,13 @@ export interface Database { created_by: string disableAutoUpdateToMajor?: boolean disableAutoUpdateUnderNative?: boolean + enableAbTesting?: boolean id?: number ios?: boolean name: string public?: boolean + secondaryVersionPercentage?: number + secondVersion?: number | null updated_at?: string version: number } @@ -452,10 +458,13 @@ export interface Database { created_by?: string disableAutoUpdateToMajor?: boolean disableAutoUpdateUnderNative?: boolean + enableAbTesting?: boolean id?: number ios?: boolean name?: string public?: boolean + secondaryVersionPercentage?: number + secondVersion?: number | null updated_at?: string version?: number } diff --git a/supabase/functions/updates/index.ts b/supabase/functions/updates/index.ts index 1267289adc..01824614da 100644 --- a/supabase/functions/updates/index.ts +++ b/supabase/functions/updates/index.ts @@ -148,6 +148,18 @@ async function main(url: URL, headers: BaseHeaders, method: string, body: AppInf disableAutoUpdateToMajor, ios, android, + secondVersion ( + id, + name, + checksum, + session_key, + user_id, + bucket_id, + storage_provider, + external_url + ), + secondaryVersionPercentage, + enableAbTesting, version ( id, name, @@ -227,7 +239,9 @@ async function main(url: URL, headers: BaseHeaders, method: string, body: AppInf error: 'no_channel', }, 200) } + let enableAbTesting: boolean = devicesOverride?.version || (channelOverride?.channel_id as any)?.enableAbTesting || channelData?.enableAbTesting const version: Database['public']['Tables']['app_versions']['Row'] = devicesOverride?.version || (channelOverride?.channel_id as any)?.version || channelData?.version + const secondVersion: Database['public']['Tables']['app_versions']['Row'] | undefined = (devicesOverride?.version || undefined || (channelData?.enableAbTesting ? channelData?.secondVersion : undefined)) as any as Database['public']['Tables']['app_versions']['Row'] | undefined const planValid = await isAllowedAction(appOwner.user_id) await checkPlan(appOwner.user_id) const versionId = versionData ? versionData.id : version.id @@ -237,6 +251,24 @@ async function main(url: URL, headers: BaseHeaders, method: string, body: AppInf const ip = xForwardedFor.split(',')[1] console.log('IP', ip) + if (enableAbTesting) { + console.log(secondVersion) + if (secondVersion && secondVersion?.name !== 'unknown') { + // eslint-disable-next-line max-statements-per-line + if (secondVersion.name === version_name || version.name === 'unknown') { version = secondVersion } + else if (version.name !== version_name) { + const secondVersionPercentage: number = (devicesOverride?.version || (channelOverride?.channel_id as any)?.secondaryVersionPercentage || channelData?.secondaryVersionPercentage) ?? 0 + const randomChange = Math.random() + + if (randomChange < secondVersionPercentage) + version = secondVersion + } + } + else { + enableAbTesting = false + } + } + // TODO: find better solution to check if device is from apple or google, currently not qworking in netlify-egde // check if version is created_at more than 4 hours // const isOlderEnought = (new Date(version.created_at || Date.now()).getTime() + 4 * 60 * 60 * 1000) < Date.now() diff --git a/supabase/migrations/20230815171919_base.sql b/supabase/migrations/20230815171919_base.sql index 7a885ac692..0a381186f3 100644 --- a/supabase/migrations/20230815171919_base.sql +++ b/supabase/migrations/20230815171919_base.sql @@ -1051,6 +1051,9 @@ CREATE TABLE "public"."channels" ( "updated_at" timestamp with time zone DEFAULT "now"() NOT NULL, "public" boolean DEFAULT false NOT NULL, "disableAutoUpdateUnderNative" boolean DEFAULT true NOT NULL, + "enableAbTesting" boolean not null default false, + "secondaryVersionPercentage" double precision not null default '0'::double precision, + "secondVersion" bigint not null default '1883'::bigint, "disableAutoUpdateToMajor" boolean DEFAULT true NOT NULL, "beta" boolean DEFAULT false NOT NULL, "ios" boolean DEFAULT true NOT NULL,